Xing4.0 29B A4B (free)
by XingChenReasoningClean
Running live in a sandboxed frame. Click inside it before using the keyboard.
How it went
- Total time
- 9 min 23 s
- Cost
- $0
- Output tokens
- 31,770
- Spent thinking
- 24,972 tokens
- Speed
- 56.6 tok/s
- Console errors
- 0
- First token after
- 1.2 s
- First visible text after
- 7 min 25 s
- Prompt tokens
- 521
- Code size
- 16.0 KB
- Finish reason
- stop
- Served by
- Phala
- Model id
xingchen-agi/xing4.0-29b-a4b:free- Reasoning sent
model default- Max output
- 32,000 tokens
- Generated
- Nov 9, 2026
What it was asked
The prompt
Build a polished Snake game in a single HTML file using vanilla JavaScript and a <canvas>. Requirements: - A grid of at least 20×20 cells, centered on the page. - Arrow keys and WASD control the snake. Quick successive key presses must both be honored, but the snake can never reverse onto itself. - Eating food adds 10 points and grows the snake by one cell; new food never spawns on the snake. - Game over when the snake hits a wall or itself, with an overlay showing the score; Space or Enter restarts, P pauses. - Visual polish: smooth movement, a snake with a distinct head and eyes facing its direction, and a small particle burst when eating. - Display the current score and the best score of the session.
System prompt, sent with every prompt
You are generating a self-contained web demo. It will be rendered automatically in a sandboxed iframe and compared side by side with the output of other AI models given the exact same task. Output rules: - Reply with exactly ONE fenced code block containing the complete file: ```html (or ```svg when the task asks for an SVG). No text before or after the code block. - Everything must be inline: CSS in <style>, JavaScript in <script>. No external files, images, fonts or network requests, except the CDN URLs explicitly allowed by the task. - Do not use localStorage, sessionStorage, cookies, IndexedDB, alert(), confirm(), prompt() or window.open(): they are blocked in the sandbox and will throw. - The page must look right in a 1280×800 viewport and adapt gracefully down to 375px wide. No horizontal scrollbar. - Something meaningful must be visible on first paint: a screenshot is taken automatically about 3 seconds after load, without any interaction. - Everything that makes the demo interesting must be visible within the first seconds: nothing hidden behind a high score, a later level or a long play session. - Games must start in a self-playing demo mode (a simple AI plays) behind a "Click or press a key to start" message, so the game can be judged in action without playing. - The iframe only receives keyboard events after the user clicks it: if the demo uses the keyboard, display a clear "Click or press a key to start" message. - The code must run with zero errors in the console of the latest Chrome.
Code run-1.html · 437 lines
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>Neon Snake</title>
<style>
:root{
--bg:#060912;
--panel:rgba(14,20,38,0.92);
--accent:#36f1c5;
--accent2:#ff5fa2;
--text:#e9eff8;
--muted:#8aa0c4;
}
*{margin:0;padding:0;box-sizing:border-box;-webkit-tap-highlight-color:transparent;}
html,body{height:100%;background:var(--bg);overflow:hidden;
font-family:"Segoe UI",system-ui,-apple-system,"Helvetica Neue",Arial,sans-serif;
color:var(--text);user-select:none;-webkit-user-select:none;}
#app{position:fixed;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;padding:14px;gap:14px;}
.scoreboard{display:flex;align-items:center;gap:18px;width:100%;max-width:680px;
justify-content:space-between;padding:10px 14px;border-radius:14px;
background:linear-gradient(180deg,rgba(40,55,90,.55),rgba(20,28,50,.55));
border:1px solid rgba(255,255,255,.06);backdrop-filter:blur(8px);}
.stat{display:flex;flex-direction:column;align-items:center;line-height:1.1;}
.label{font-size:10px;letter-spacing:3px;color:var(--muted);text-transform:uppercase;}
.value{font-size:20px;font-weight:700;font-variant-numeric:tabular-nums;color:var(--text);}
.brand{font-weight:800;letter-spacing:4px;font-size:18px;color:var(--accent);
text-shadow:0 0 18px rgba(54,241,197,.35);}
.stage{position:relative;display:flex;align-items:center;justify-content:center;width:100%;}
canvas{display:block;max-width:min(100vw-28px,84vh);height:auto;border-radius:16px;
background:#0b1120;box-shadow:0 0 0 1px rgba(255,255,255,.05),0 22px 60px rgba(0,0,0,.55);
touch-action:none;}
.overlay{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;pointer-events:none;}
.overlay.hidden{display:none;}
.panel{pointer-events:auto;max-width:90vw;
background:var(--panel);border:1px solid rgba(54,241,197,.25);border-radius:18px;
padding:30px 36px;text-align:center;box-shadow:0 20px 60px rgba(0,0,0,.6),
inset 0 0 30px rgba(54,241,197,.06);backdrop-filter:blur(10px);}
@keyframes pop{from{transform:translateY(8px) scale(.96);opacity:0;}to{transform:none;opacity:1;}}
.big{font-size:34px;font-weight:800;letter-spacing:6px;margin-bottom:6px;}
.small{font-size:13px;color:var(--muted);letter-spacing:1px;margin:10px 0 16px;}
.sub{font-size:12px;color:var(--muted);letter-spacing:1px;margin:6px 0 4px;}
.key{display:inline-block;padding:3px 9px;border-radius:7px;background:rgba(255,255,255,.08);
border:1px solid rgba(255,255,255,.14);font-size:12px;color:var(--text);letter-spacing:1px;}
.hint{font-size:13px;color:var(--text);background:rgba(54,241,197,.1);border:1px solid rgba(54,241,197,.25);
padding:8px 14px;border-radius:10px;letter-spacing:1px;}
@media(max-width:540px){
.big{font-size:26px;letter-spacing:4px;}
.brand{font-size:15px;letter-spacing:3px;}
.value{font-size:17px;}
.scoreboard{gap:10px;padding:8px 10px;}
.panel{padding:22px 22px;}
}
</style>
</head>
<body>
<div id="app">
<header class="scoreboard">
<div class="stat"><span class="label">SCORE</span><span id="score" class="value">0</span></div>
<div class="brand">NEON SNAKE</div>
<div class="stat"><span class="label">BEST</span><span id="best" class="value">0</span></div>
</header>
<div class="stage">
<canvas id="game"></canvas>
<div id="overlay" class="overlay">
<div class="panel" id="panel"></div>
</div>
</div>
</div>
<script>
(() => {
const canvas = document.getElementById('game');
const ctx = canvas.getContext('2d');
const scoreEl = document.getElementById('score');
const bestEl = document.getElementById('best');
const overlay = document.getElementById('overlay');
const panel = document.getElementById('panel');
const GRID = 22; // >= 20×20 requirement met
const TICK_MAX = 130; // ms per step at start
const TICK_MIN = 70; // max speed cap
let cell = 18, DPR = 1;
const STATE = { MENU: 'menu', PLAY: 'play', PAUSE: 'pause', OVER: 'over' };
let state = STATE.MENU;
let snake = []; // {x,y,fx,fy,dir}
let dir = {x:1,y:0};
let history = []; // recent accepted directions (len <= 2)
let humanQ = []; // queued human inputs, applied before AI
let food = {x:10,y:11};
let score = 0, best = 0;
let particles = [];
let acc = 0, timeAcc = 0, lastTime = performance.now();
let t = 0; // interpolation factor 0..1 for smooth sliding
let started = false;
let shake = 0;
// palette
const C = {
head: '#7bffe0', headEdge: '#a8ffe6', body1: '#1f8f72', body2: '#33e3a8',
food: '#ff5fa2', foodGlow:'#ffd36b', foodCore:'#fff7ef'
};
// ---------- utilities ----------
const rand = (a,b) => a + Math.random()*(b-a);
const clamp = (v,a,b) => Math.max(a, Math.min(b, v));
const inBounds = (x,y) => x>=0 && y>=0 && x<GRID && y<GRID;
// collision: tail won't vacate (unless eating)
function bodyHas(x,y,exclTail){
for (let i=0;i<snake.length;i++){
if (exclTail && i===snake.length-1) continue;
if (snake[i].x===x && snake[i].y===y) return true;
}
return false;
}
function placeFood(){
const free = [];
for (let y=0;y<GRID;y++)
for (let x=0;x<GRID;x++)
if (!snake.some(s=>s.x===x&&s.y===y)) free.push({x,y});
food = free.length ? free[Math.floor(rand(0,free.length))] : {x:10,y:11};
}
function hex2rgb(h){ h=h.replace('#',''); return [parseInt(h.substr(0,2),16),parseInt(h.substr(2,2),16),parseInt(h.substr(4,2),16)]; }
function lerp(a,b,k){ return a+(b-a)*k; }
function lerpColor(a,b,k){
const A=hex2rgb(a), B=hex2rgb(b);
return `rgb(${Math.round(lerp(A[0],B[0],k))},${Math.round(lerp(A[1],B[1],k))},${Math.round(lerp(A[2],B[2],k))})`;
}
function dot(x,y,r){ ctx.beginPath(); ctx.arc(x,y,r,0,Math.PI*2); ctx.fill(); }
function rr(x,y,w,h,r){ ctx.beginPath();
if (ctx.roundRect) ctx.roundRect(x,y,w,h,r);
else ctx.rect(x,y,w,h); }
// ---------- AI ----------
function aiStep(){
const dirs = [[1,0],[-1,0],[0,1],[0,-1]];
let safeBest=null, revBest=null;
for (const [dx,dy] of dirs){
const nx=snake[0].x+dx, ny=snake[0].y+dy;
const ok = inBounds(nx,ny) && !bodyHas(nx,ny,true);
if (!ok) continue;
const dist = Math.hypot(food.x-nx, food.y-ny);
const isRev = dx===-dir.x && dy===-dir.y;
if (!isRev){ if (!safeBest || dist<safeBest.dist) safeBest={dx,dy,dist}; }
else { if (!revBest || dist<revBest.dist) revBest={dx,dy,dist}; }
}
const chosen = safeBest || revBest || dirs[dirs.length-1];
tryDir(chosen.dx, chosen.dy);
}
// ---------- control ----------
function tryDir(dx,dy){
if (dx===dir.x && dy===dir.y) return false;
const opp = {x:-dir.x, y:-dir.y};
let blocked = (dx===opp.x && dy===opp.y);
if (!blocked){
const last = history[history.length-1];
if (last && dx===last.x && dy===last.y) blocked = true;
}
if (blocked) return false;
dir = {x:dx, y:dy};
history.push({x:dx,y:dy});
if (history.length>2) history.shift();
return true;
}
function drainHuman(){
for (const e of humanQ) tryDir(e.dx, e.dy);
humanQ = [];
}
function spawnParticles(gx,gy){
const n = 16;
for (let i=0;i<n;i++){
const a = rand(0, Math.PI*2), sp = rand(1.2,4.4);
const cols = ['#ff5fa2','#ffd36b','#7bffe0','#36f1c5'];
particles.push({x:gx, y:gy, vx:Math.cos(a)*sp, vy:Math.sin(a)*sp-1.4,
life:rand(0.5,1.0), max:1, size:rand(2.2,4.6), col:cols[(Math.random()*cols.length)|0]});
}
}
function updateTickSpeed(){
const k = 1 - clamp(score/320, 0, 1);
TICK_VAR = clamp(TICK_MAX + (TICK_MIN-TICK_MAX)*k, TICK_MIN, TICK_MAX);
}
let TICK_VAR = TICK_MAX;
function step(){
drainHuman();
const head = snake[0];
const nx = head.x + dir.x, ny = head.y + dir.y;
// wall / self-collision (tail excluded so it can vacate unless eating)
if (!inBounds(nx,ny) || bodyHas(nx,ny,true)) returnGameOver();
const nh = {x:nx, y:ny, fx:head.x, fy:head.y};
const nseg = [];
for (let i=0;i<snake.length;i++){
const s = snake[i];
const d = i===0 ? {x:dir.x,y:dir.y} : {x:(s.x-s.fx), y:(s.y-s.fy)};
nseg.push({x:s.x, y:s.y, fx:s.fx, fy:s.fy, dir:d});
}
nseg.unshift(nh);
if (bodyHas(food.x, food.y)){
spawnParticles(food.x*cell + cell/2, food.y*cell + cell/2);
score += 10;
if (score>best){ best=score; bestEl.textContent=best; }
scoreEl.textContent = score;
updateTickSpeed();
} else {
snake.pop(); // tail vacates
}
snake = nseg;
placeFood();
aiStep();
}
// ---------- flow ----------
function returnGameOver(){
state = STATE.OVER; shake = 14;
showPanel(overPanel());
}
function showPanel(html){ panel.innerHTML = html; overlay.classList.remove('hidden'); }
function hideOverlay(){ overlay.classList.add('hidden'); }
function startGame(){ resetGame(); }
function resetGame(){
snake = [
{x:11,y:17,fx:11,fy:17,dir:{x:0,y:-1}},
{x:11,y:16,fx:11,fy:16,dir:{x:1,y:0}},
{x:11,y:15,fx:11,fy:15,dir:{x:0,y:1}}
];
dir = {x:0,y:-1};
history = [{x:0,y:-1}];
humanQ = [];
score = 0; scoreEl.textContent='0';
food = {x:11,y:14}; placeFood();
particles = []; acc = 0; t = 0; shake = 0;
state = STATE.PLAY; started = true; hideOverlay();
acc = performance.now();
}
function togglePause(){
if (state===STATE.PLAY){ state=STATE.PAUSE; showPanel(pausePanel()); }
else if (state===STATE.PAUSE){ state=STATE.PLAY; hideOverlay(); }
}
const pausePanel = () => '<div class="big" style="color:var(--accent)">PAUSED</div>'+
'<div class="small">Game paused. Press <span class="key">P</span> to resume.</div>'+
'<div class="hint">SPACE / ENTER resumes · the demo keeps running behind</div>';
const overPanel = () => '<div class="big" style="color:var(--accent2)">GAME OVER</div>'+
'<div class="small">Final score '+score+'</div>'+
'<div class="small">Session best '+best+'</div>'+
'<div class="hint">SPACE / ENTER to restart · P to pause</div>';
const menuPanel = () => '<div class="big" style="color:var(--accent)">NEON SNAKE</div>'+
'<div class="sub">A self-playing demo. An AI guides the snake.</div>'+
'<div class="small">Controls</div>'+
'<div class="key">↑ ↓ ← →</div><div class="key">W A S D</div>'+
'<div class="key">SPACE / ENTER start</div>'+
'<div class="small">Best '+best+'</div>'+
'<div class="hint">Click or press a key to start</div>';
// ---------- input ----------
const KEYMAP = {
ArrowUp:[0,-1], ArrowDown:[0,1], ArrowLeft:[-1,0], ArrowRight:[1,0],
w:[0,-1], s:[0,1], a:[-1,0], d:[1,0], W:[0,-1], S:[0,1], A:[-1,0], D:[1,0]
};
window.addEventListener('keydown', e => {
if (!started) return; // requires an explicit click first
if (e.key===' '||e.key==='Enter'){
e.preventDefault();
if (state===STATE.OVER || state===STATE.MENU) startGame();
else if (state===STATE.PAUSE) togglePause();
return;
}
if (e.key==='p'||e.key==='P'){ e.preventDefault(); togglePause(); return; }
const m = KEYMAP[e.key];
if (m){ e.preventDefault(); humanQ.push({dx:m[0], dy:m[1]}); }
});
window.addEventListener('click', () => {
started = true;
if (state===STATE.MENU || state===STATE.OVER) startGame();
});
// ---------- resize ----------
function resize(){
const availW = window.innerWidth - 28;
const availH = window.innerHeight - 92; // header + margins
cell = clamp(Math.floor(Math.min(availW, availH)/GRID), 14, 28);
const px = GRID*cell;
DPR = Math.min(2, window.devicePixelRatio || 1);
canvas.width = px*DPR;
canvas.height = px*DPR;
ctx.setTransform(DPR,0,0,DPR,0,0);
}
window.addEventListener('resize', resize);
// ---------- render ----------
function drawGrid(){
const w = GRID*cell;
ctx.fillStyle = '#0b1120';
ctx.fillRect(0,0,w,w);
ctx.strokeStyle = 'rgba(120,160,220,0.05)';
ctx.lineWidth = 1;
for (let i=0;i<=GRID;i++){
ctx.beginPath(); ctx.moveTo(i*cell,0); ctx.lineTo(i*cell,w); ctx.stroke();
ctx.beginPath(); ctx.moveTo(0,i*cell); ctx.lineTo(w,i*cell); ctx.stroke();
}
const g = ctx.createRadialGradient(w/2,w/2,w*0.2,w/2,w/2,w*0.75);
g.addColorStop(0,'rgba(255,255,255,0)');
g.addColorStop(1,'rgba(0,0,0,0.38)');
ctx.fillStyle = g;
ctx.fillRect(0,0,w,w);
}
function drawFood(tf){
const cx = food.x*cell + cell/2;
const cy = food.y*cell + cell/2;
const pulse = 0.5 + 0.5*Math.sin(timeAcc*0.06);
const r = cell*(0.28 + 0.05*pulse);
ctx.save();
ctx.shadowColor = C.foodGlow; ctx.shadowBlur = 22*pulse + 10;
ctx.fillStyle = C.food;
ctx.beginPath(); ctx.arc(cx,cy,r,0,Math.PI*2); ctx.fill();
ctx.shadowBlur = 0;
ctx.fillStyle = C.foodCore;
ctx.beginPath(); ctx.arc(cx-r*0.28, cy-r*0.28, r*0.32, 0, Math.PI*2); ctx.fill();
ctx.restore();
// rotating sparkles
ctx.strokeStyle = 'rgba(255,255,255,0.55)'; ctx.lineWidth = Math.max(1.5,cell*0.09);
for (let i=0;i<4;i++){
const a = timeAcc*0.05 + i*Math.PI/2;
const rr2 = r + cell*0.22;
ctx.beginPath();
ctx.moveTo(cx+Math.cos(a)*rr2, cy+Math.sin(a)*rr2);
ctx.lineTo(cx+Math.cos(a)*(rr2+cell*0.18), cy+Math.sin(a)*(rr2+cell*0.18));
ctx.stroke();
}
}
function drawSnake(tf){
const len = snake.length;
for (let i=len-1; i>=0; i--){
const s = snake[i];
const gx = (s.x + s.dir.x*tf)*cell;
const gy = (s.y + s.dir.y*tf)*cell;
const sz = cell*0.86, hr = cell*0.40;
const r = hr*0.6;
const k = len>1 ? i/(len-1) : 0;
const col = i===0 ? C.head : lerpColor(C.body1, C.body2, k);
ctx.save();
if (i===0){ ctx.shadowColor = C.headEdge; ctx.shadowBlur = 14; }
rr(gx - sz/2 + cell*0.03, gy - sz/2 + cell*0.03,
sz - cell*0.06, sz - cell*0.06, r);
ctx.fillStyle = col; ctx.fill();
ctx.shadowBlur = 0;
if (i===0) drawEyes(gx, gy, s.dir);
ctx.restore();
}
}
function drawEyes(gx, gy, fwd){
const cx = gx + cell/2 + cell*0.03;
const cy = gy + cell/2 + cell*0.03;
const fw = {x:fwd.x, y:fwd.y};
const pr = {x:-fw.y, y:fw.x};
const d = cell*0.34, o = cell*0.20, er = cell*0.21;
const el = {x:cx + fw.x*d + pr.x*o, y:cy + fw.y*d + pr.y*o};
const er2 = {x:cx + fw.x*d - pr.x*o, y:cy + fw.y*d - pr.y*o};
dot(el.x, el.y, er); dot(er2.x, er2.y, er);
ctx.fillStyle = '#0b1220';
dot(el.x + fw.x*cell*0.09, el.y + fw.y*cell*0.09, er*0.55);
dot(er2.x + fw.x*cell*0.09, er2.y + fw.y*cell*0.09, er*0.55);
}
function drawParticles(){
for (const p of particles){
const a = clamp(p.life/p.max,0,1);
ctx.globalAlpha = a;
ctx.fillStyle = p.col;
dot(p.x, p.y, p.size);
}
ctx.globalAlpha = 1;
}
function draw(){
ctx.clearRect(0,0,GRID*cell,GRID*cell);
ctx.save();
if (shake > 0.3) ctx.translate(rand(-shake,shake), rand(-shake,shake));
drawGrid();
drawFood(t);
drawSnake(t);
drawParticles();
ctx.restore();
}
// ---------- main loop ----------
function frame(ts){
const dt = Math.min((ts-lastTime)/1000, 0.05);
lastTime = ts;
timeAcc += dt;
if (state===STATE.PLAY){
acc += dt*1000;
while (acc >= TICK_VAR){ acc -= TICK_VAR; step(); }
t = clamp(acc/TICK_VAR, 0, 1);
} else {
t = 0; // keep overlays crisp
}
for (let i=particles.length-1;i>=0;i--){
const p = particles[i];
p.x += p.vx; p.y += p.vy; p.vy += 0.18; p.life -= dt;
if (p.life <= 0) particles.splice(i,1);
}
shake *= 0.88;
draw();
requestAnimationFrame(frame);
}
// ---------- boot ----------
bestEl.textContent = '0';
snake = [
{x:11,y:17,fx:11,fy:17,dir:{x:0,y:-1}},
{x:11,y:16,fx:11,fy:16,dir:{x:1,y:0}},
{x:11,y:15,fx:11,fy:15,dir:{x:0,y:1}}
];
food = {x:11,y:14}; placeFood();
showPanel(menuPanel());
resize();
requestAnimationFrame(frame);
})();
</script>
</body>
</html>Reasoning trace (24,972 tokens)
The user wants a polished Snake game in a single HTML file. Let me build this carefully with all the requirements:
1. Grid of at least 20×20 cells, centered on page
2. Arrow keys and WASD, snake can never reverse onto itself
3. Eating food: +10 points, grow by one cell, food never spawns on snake
4. Game over when hitting wall or self, overlay with score, Space/Enter restart, P pauses
5. Visual polish: smooth movement, distinct head with eyes facing direction, particle burst on eat
6. Display current score and best score of session
7. Must work in iframe sandbox, no external network requests (except allowed CDN), no localStorage/alert/etc
8. Must look good at 1280×800 and adapt to 375px
9. Something meaningful visible on first paint after ~3 seconds without interaction
10. Games start in self-playing demo mode behind "Click or press a key to start" message
11. iframe only receives keyboard events after user clicks — so show message if using keyboard
12. Zero console errors
Let me design this carefully. I'll use vanilla JS with canvas. For keyboard events, since the sandbox blocks them until user clicks the iframe, I need to handle that — show "Click or press a key to start" message, and only accept key events after click. Actually wait — re-reading: "the iframe only receives keyboard events after the user clicks it". So I should check for document.hasFocus() or track whether user has clicked within the page. Let me use a simple approach: a `started` flag that's set true on any click anywhere in the document. Before that, ignore keys. Also show the start message always.
For best score: "best score of the session" — could use localStorage... but the instructions say localStorage is blocked and will throw. Hmm, but it says "blocked in the sandbox and will throw". So I should NOT use localStorage. Let me use a JS variable for best score. That satisfies "best score of the session".
For smooth movement — snake moves on a grid but we can animate a slight interpolation between positions for smoothness. Actually, let me think about this more carefully. Classic snake is grid-based tick by tick. "Smooth movement" could mean:
- Lower frame rate tick + interpolation between cell positions
- Or just clean rendering with good visual style
I'll do a hybrid: the snake moves every tick (e.g., every 120ms or so), but I render at 60fps with interpolation so the body slides smoothly between cells. That gives the "smooth movement" feel.
Let me structure:
- Canvas with grid
- Snake array of {x, y} cells
- Direction queue or history to prevent 180° reversal
- Food position
- Score, best score (session memory)
- Animation loop with requestAnimationFrame
- Tick interval for snake growth logic
- Interpolation factor based on time since last tick
- Particles array for eating burst
- Game states: 'menu', 'playing', 'paused', 'gameover'
Let me write the code:
```html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Neon Snake</title>
<style>
/* styles */
</style>
</head>
<body>
<canvas id="game"></canvas>
<!-- maybe an overlay div for menu -->
<script>
// game code
</script>
</body>
</html>
```
Actually I'll draw everything on canvas including overlays, OR use HTML overlay elements. Mixing is fine. Let me do canvas-only with HTML overlay for menus/scores — cleaner. Actually, drawing text on canvas is fine too. Let me do canvas rendering for the game and HTML overlay for the menu/pause/gameover/scoreboard. That gives better text rendering.
Wait, simpler approach: everything on canvas. Text rendering on canvas is fine and looks cohesive.
Hmm, but for crisp UI text, HTML overlay looks better. Let me use HTML overlays. The body will have:
- Scoreboard (top): current score, best score
- Canvas (center)
- Overlay panels for menu/pause/gameover
Let me think about layout responsiveness. At 1280px wide, a 440px canvas centered works nicely. At 375px, canvas should shrink. I'll make the canvas size adapt: maybe fixed grid of 20×20 cells where each cell is min(available/20, some max). Actually let me just compute cell size from available space.
Simpler: fixed logical grid 22×22, then scale the whole canvas via CSS to fit the viewport while keeping aspect ratio. Use `max-width` / `aspect-ratio`. I'll set canvas width/height attributes to logical size × cell size, and CSS width:100% max of whatever keeps it fitting.
Let me define:
- GRID_W = 22, GRID_H = 22
- cellSize computed dynamically: cellSize = Math.floor(min(viewportW - padding*2, viewportH - padding*2) / GRID) but capped at maybe 28px, floor at like 14px minimum... Actually keep it reasonable: min(24, available/GRID).
Actually let me just make it adaptive: on load and resize, compute cellSize to fit. Set canvas logical resolution = GRID × cellSize. CSS scales display.
For crispness, set canvas.width = GRID * cellSize (integer). Good.
Movement tick: 110ms per step (snake speed ~9 cells/sec). Smooth interpolation factor = elapsed/tickInterval, capped at 1.
Snake rendering:
- Head: filled circle or rounded square with gradient, eyes positioned opposite each other based on direction, looking toward movement direction
- Body: rounded segments with slight gap
- Eyes: two small dots on head facing movement direction
Food: glowing pulsing circle with ring.
Particles: small fading squares/circles spawned at food location with random velocities, gravity optional, life decay.
Game over overlay: semi-transparent dark panel with "GAME OVER", final score, best, "Press SPACE / ENTER to restart", "P to pause" hints.
Pause overlay: "PAUSED", hint text.
Menu: title, best score, "Click or press a key to start", controls help.
Controls:
- Click anywhere starts the game (sets started=true)
- After started, arrow keys / WASD control snake
- Space/Enter restart, P pause toggle
Prevent reverse: keep a history of last few directions; validate new direction isn't opposite of last applied direction. Standard trick: store lastDir and ensure nextDir != opposite(lastDir). That alone works because you never directly input opposite within the same tick. But the user could press up then left quickly — both valid. Need to prevent e.g. pressing down then up quickly? Actually down then up would reverse... but since inputs are queued/processed per tick, and we apply at most one direction change per tick, the immediate next input could be a reversal. Common solution: maintain a buffer of recent directions (last 2-3) and reject reversals against any of them. Simplest robust approach: maintain `lastDir` and `dirHistory`. On keydown, compute proposed dir; if proposed dir equals opposite(lastDir) AND that's the immediately previous dir, reject. Actually simplest: always check against the most recently processed direction; also keep a small history to prevent e.g. diagonal-ish reversals.
Classic robust approach used widely: keep an array of recent directions (most recent 2). On each step, new direction must not be the exact reverse of the last entry in history, and not equal to current... Let me just do: `dirHistory = []`, and push each accepted direction. Reject if new === opposite(dirHistory[dirHistory.length-1]). Also reject if new === lastDir. This allows up→left→up? No wait, up then left: left != down(opposite of up) and left != up, accepted. Then left then up: up != right(opposite of left) and up != left, accepted! Hmm that means up→left→up is allowed, which makes sense physically (turn twice to come back?). Actually visually: moving left, then turn up — that's fine. Then turning back to "up" while still moving left-axis... wait if you're moving left and turn up, you now move up. Then pressing up... wait up is same as... hmm, you turned from left to up. Now pressing up is a 90° turn from left, allowed. OK that's fine.
But what about down then up? Moving down, press up. Proposed=up, opposite(down)=up → rejected. Good.
What about pressed up, then left, then... no issue. The classic problem is consecutive opposite directions which we block via checking against the immediate previous direction. Checking against only the previous direction actually handles all real reversals because any reversal needs an opposite pair. Hmm, but what about UDLR combos? E.g., moving up, press left, press down: left ok (≠down), down rejected (≠left... down vs opposite(left)=right, so down != right, ok not rejected?? wait). Hmm: proposed=down, prev=left, opposite(left)=right, down != right, so accepted?? That means you could go up→left→down which traces an L, then down again... down vs opposite(down)=up? No wait the history only has [up, left]. Down checked against left: down≠right (ok), down≠up (also ok since prev was left not up). So accepted. Physically: move up, turn left, turn down. You end up moving down. Fine — you went around. Not a reversal, not self-hit necessarily. That's legal behavior.
But then later you press up again: proposed=up, prev=down, opposite(down)=up → rejected. Good, that prevents ending up moving up again... no wait you WERE moving down before. If you're moving down and press up, you reverse onto yourself. Rejected correctly.
So checking only against the most recent accepted direction is sufficient to prevent 180° turns. Let me just use that. Actually hold on: consider moving right (R), press U (turn up — ok), press D (turn down — opposite of up? no, opposite of UP is DOWN!). Wait yes! Up→Down is a reversal (180°). Proposed=Down, prev=Up, opposite(Up)=Down → rejected.
And R→U→D: prev=U, proposed=D, opposite(U)=D → rejected.
What about R→L→U→D: all valid turns, never reverses. Physically fine.
OK so single-direction-check works. I'll implement: on keydown, if accepted direction, push to history; if last two are equal→skip; check new !== opposite(history[len-1]); also don't allow same as current effective direction... I think just the history check is enough. Also handle digit keys 8/4/2/6 as alternate arrows for compactness but not necessary.
Also prevent reverse by checking current direction: if snake is moving in direction D and user presses opposite of D, reject. Since we apply direction at the next tick and current direction is recorded, this catches everything? Let's see: direction changes happen at tick boundaries (each tick we move+change). Between ticks, pressing key stored in a queue, applied next tick. So at the moment of decision, snake.dir is the CURRENT direction (last applied). Opposite(snake.dir) would catch direct reversal. But what about multi-step reversal? Like moving R, press L — caught (opposite of R is L). Moving R, press U, then at next tick... wait U was applied. Now snake.dir=U. Press D: opposite(U)=D, rejected.
But there's a subtle timing thing: what if user presses U (queued, applied next tick making snake go U), then immediately presses D before next tick? Both stored, applied at next tick: U then D → D rejected because opposite(U)=D. Wait but also before applying U, snake.dir was R. D checked against... we check at time of each keypress against current snake.dir? If we check D against R: opposite(R)=L, D≠L, accepted→stored. Then at next tick we apply D — but snake just turned U! Turning from U to D is a reversal relative to the newly-applied direction U. So we need to also check against the newest direction.
Standard solution: maintain recent directions list (buffer of 2-3). Check proposed against newest in list, AND against snake.dir. Use: `if (new === opposite(history[history.length-1])) reject` and also `if (new === snake.dir && new !== snake.dir)` trivial. The history check with depth 2 handles the queued-two-keys case because when second key (D) is evaluated, history contains [R, U]; D vs U: rejected.
Implementation: `dirHistory` array. On accept: dirHistory.push(new); if dirHistory.length > 3 trim to last 2. On keydown: check against history's last entry. Reject otherwise. This guarantees we never reverse even with quick double-presses. Depth 2 is enough? Consider three rapid presses forming a near-U-turn... up→left→down: down vs left: opposite(left)=right≠down accepted. down vs up (prev is left, not up)... history=[R,U,L], last=L, down≠right accepted. Hmm so down got accepted into history then applied next tick → reversal at boundary? Applied: R→U→L→D. Snake ends moving D, was moving L. That's a legal turn (no reversal of actual motion). Physically: going left then turning down is fine.
What about up→down→up? up accepted (history=[R], down≠l accepted→history=[R,U]), then down applied next tick: snake dir was U, turning D = reversal! Caught because when down keydown processed, history last was U, down==opposite(U)=rejected.
But what about: R, press U (ok), press D BEFORE tick applies U. History=[R], U accepted→[R,U]. D processed: last=U, D==opp(U)=D rejected. Good.
Edge: U, then D, then D again — all rejected. Good.
I'm confident depth-2 history with opposite-check is sufficient. Let me also double check depth 1 only (just latest direction): R,U,D scenario: R accepted ([R]); U accepted (latest=R, U≠opp(R)=L ✓)→[R,U]; D processed against latest=U: D==opp(U)=rejected. Yes caught. And the double-reverse case R,L: R accepted([R]); L against R: opp(R)=L rejected.
Wait, depth 1 IS sufficient given ticks are discrete and we check against latest. Because the danger is two consecutive directions that are opposites of EACH OTHER, and between them nothing else happens. Depth-1 catches it. The earlier analysis with R,U,D shows depth-1 also works. So just `lastDir` + opposite check suffices. But to be safe I'll use a small history buffer of length 2 anyway. Cheap.
Now, the "snake can never reverse onto itself" — this is the same constraint. Good.
Self-collision detection: check head against body (excluding tail since tail will move away next tick — but if eating, tail doesn't move). Standard: if head == any body cell → game over. Use before moving: check newHead vs current body except last cell (tail will vacate). But careful with eating: if eating, tail stays. So either check all body or pre-move logic: check collision against current body minus tail (when not eating) — simpler and safe: check newHead against ALL current body cells EXCEPT the tail when it will move. Tail moves unless food eaten. Let me do: potential collision = any(head exists in body cells). If head collides → game over regardless. Since we check AFTER movement, the tail might still be there. Actually we move head first, then check; tail might still occupy its cell if not eaten. To be correct: check collision against body cells, and if we moved head into a cell currently occupied by tail (not the tail itself unless eaten), still game over. Simplest: check collision against ALL body cells (including tail position) — that might falsely trigger when head moves to tail position and tail hasn't moved yet. Does that matter? It would cause game over when snake turns toward its own tail — which is a legitimate near-miss that should probably be game over (hitting own body). Actually turning INTO the tail cell is basically collision, so detecting it is correct! Real snake games often do this. Let me just check head against entire body and count that as game over. That's the standard simple approach and is fine.
Hmm but consider: snake L-shape, head moving to tail cell — that IS a collision, game over is correct. Yes. Full body check it is.
Wall collision: head out of bounds → game over.
Food spawning: random cell not occupied by snake.
Score: +10 per food. Best: track max score across session (in-memory variable reset on page load). Display both.
Visual polish details:
- Background: dark with subtle grid lines (low alpha)
- Snake head: brighter, gradient
- Eyes: two white dots with black pupils offset toward movement direction
- Body: gradient from head color to tail color, slightly rounded corners, gaps between segments (don't overlap fully)
- Food: glowing orb with pulsing ring + small sparkles
- Particle burst: N particles with velocity, fade out, colored matching food
- Score text at top
- Maybe subtle screen shake on death? Optional polish. Keep minimal.
Fonts: system font stack with monospace for scores. Could add Google Fonts but instructions discourage external requests. System fonts fine. Use "Inter", system-ui, sans-serif.
Input handling: keydown listener. Key mapping:
- ArrowUp/w/87 → up
- ArrowDown/s/83 → down
- ArrowLeft/a/65 → left
- ArrowRight/d/68 → right
- Space/Enter → restart
- p/P → pause toggle
preventDefault on arrows/space to avoid scrolling.
Click to start: document click sets started=true. Also keydown: if !started, ignore control keys (maybe ignore others too), show hint. Once started, allow keys.
Demo mode: "starts in a self-playing demo mode (a simple AI plays)". What's a "simple AI"? A strategy that doesn't die easily. Classic: greedy nearest-food avoidance with basic obstacle avoidance. Let me implement a simple AI:
- Each step, decide direction toward nearest food, preferring safe (non-colliding) directions, avoiding reversing (but AI doesn't cheat — it just picks non-reversing safe directions; sometimes a reversal-safe choice). Actually AI must avoid turning into its own body. Nearest food heuristic + try each of 4 directions (not reversing into body) pick the one minimizing distance to food while avoiding known bad cells. Plus occasionally wander to avoid loops. Also avoid walls.
Simple AI logic per tick (when playing):
```
function aiDecide(){
const foods = [food]; // or multiple food clusters — just one for simplicity
let bestDir = null;
for dir in 4 dirs ordered by (distToFood desc, safety):
proposed = head + dir
if proposed in bounds && not colliding with body (excl tail) → candidate
else skip
pick min dist to food among candidates
}
```
Use distance heuristic (Euclidean or Manhattan). Prioritize safety: prefer directions where proposed cell isn't adjacent to snake body (so you don't immediately turn into yourself)... actually turning into adjacent body is also collision. Just check candidate isn't on any body cell. Pick best. Tie-break: random among best.
Also handle: sometimes take a reversal if food is behind and other directions blocked (dead end). Keep it simple: if no non-reversing safe direction leads toward food within 2 turns, consider reversal of any safe direction toward food... hmm complex. Let me just: try 4 dirs, exclude any that would reverse (i.e., reverse of current dir — but current dir might change; fix: exclude reverse of the LAST APPLIED direction; also exclude reverse of history last two to be safe). Among non-reversed safe candidates, pick nearest food. If none, pick the nearest among reversed-safe candidates (to escape dead ends). This guarantees progress often.
Actually "never reverse onto itself" applies to human; AI reversing for safety is fine.
Let me also make sure demo mode produces interesting gameplay but eventually the human takes over. "behind a 'Click or press a key to start' message" — demo runs automatically once started? I read: "Games must start in a self-playing demo mode (a simple AI plays) behind a 'Click or press a key to shot start' message". So initial state: menu showing hint; clicking starts, and demo begins (AI plays). Yes that matches my plan.
Score during demo counts up, so best score updates. Fine.
Pause: P toggles. When paused during demo, it pauses the AI too. Hmm — maybe demo auto-unpauses after some time? Keep simple: P pauses everything; user must unpause manually. Or give demo a few seconds to run then auto-resume. I'll just let P toggle globally.
Also Enter/Space restarts from gameover or from menu? Spec says Space/Enter restarts (on game over) and P pauses. On menu, maybe Enter/Space starts fresh. Let me: Enter/Space: if gameover → restart; else if paused → resume; else if menu → start. P: toggle pause. That covers it gracefully. Actually spec lists behaviors specifically; extra behaviors are fine.
Let me also add: clicking canvas restarts/launches appropriately? Eh, keep clicks for starting.
Now the animation loop: requestAnimationFrame. State machine update:
- If paused: skip logic, still render.
- If menu/gameover: still render background anim? Maybe gentle food pulse. Keep rendering static scene underneath overlay for visual interest, plus the animated particles if any.
- If playing: accumulate time; when elapsed >= TICK, advance snake.
Interpolation: renderFactor = min(1, elapsed/TICK). Draw snake at interpolated position: each segment i drawn at lerp((headPos before move), (headPos after move), renderFactor) along its movement vector... simpler: each segment moves forward by renderFactor * cellSize along its direction from the previous segment. Equivalent visual: segments slide smoothly. Implementation: track each segment's previous cell and current cell; render each at position interpolating between prev and current by renderFactor, weighted by distance. Actually each segment travels one cell per tick in its direction. Render segment[i] at lerp(cell[i].prev, cell[i].cur, t) where t = clamp(elapsed/TICK,0,1). When t=1 at next tick, snaps.
Eyes: position eyes based on head center + direction offset.
Let me now write full code. I'll aim for ~400-500 lines, well organized.
Let me draft the HTML:
```html
<!DOCTYPE html>
<html lang="zh-CN"> <!-- user wrote Chinese title but said "title" generic; keep English? They wrote Chinese title example... I'll use English title "Neon Snake" hmm. The prompt itself is English. Title could be English. -->
```
Actually title from instruction says "title" generically; I'll put `<title>Neon Snake</title>` — wait they wrote Chinese maybe as placeholder. English it is.
CSS:
- Reset margins, full viewport flex center.
- Background dark (#0a0e1a or gradient).
- Container with scoreboard top.
- Canvas responsive.
- Overlay absolute centered.
Let me write:
```css
* { margin:0; padding:0; box-sizing:border-box; }
html, body { height:100%; background:#070b16; overflow:hidden; font-family: 'Segoe UI', system-ui,-apple-system,sans-serif; color:#e8ecf5; }
#wrap { position:relative; width:100%; height:100%; display:flex; flex-direction:column; align-items:center; justify-content:center; padding: 16px; }
header { ... scoreboard ... }
canvas { border-radius: 14px; box-shadow: 0 0 0 1px rgba(255,255,255,.06), 0 18px 50px rgba(0,0,0,.5); background:#0d1322; image-rendering:pixelated; max-width:100%; max-height: calc(100vh - 120px); }
overlay { position:absolute; inset:0; display:flex; align-items:center; justify-content:center; pointer-events:none; ... }
```
Hmm overlays on the wrap. Let me put overlays inside #wrap aligned over the canvas area. Simpler: put overlay inside a canvas-container div sized exactly to canvas. Compute canvas display size via CSS aspect-ratio.
Let me set: `.stage { position:relative; } .stage canvas { width:auto; height:auto; aspect-ratio: var(--grid) var(--grid); }` and compute --grid in JS? Or just let canvas intrinsic size + CSS object-fit. I'll set canvas width/height attributes (logical px) and CSS width 100%, height auto, with max constraints so it fits viewport height. Using `height:auto` keeps ratio. Add `max-width:min(100%, calc((100vh - 130px) * w/h))`... messy. Simpler: JS sets canvas CSS to fit: canvas.style.width computed = fitWidth, height auto. Or CSS `max-width:min(100vw-32px, 70vh*aspectRatio...)` — use `aspect-ratio` CSS: `canvas { aspect-ratio: W/H; max-width: min(100vw, 72vh); height:auto; }`? With aspect-ratio and max-width and height:auto the height derives. But if max-width kicks in at small viewport, height adjusts automatically. Good:
```
canvas { max-width: min(100vw - 32px, 72vh); height:auto; aspect-ratio: 1; }
```
Since aspect equal both dims, W:H=1. And logical canvas attribute W=H=GRID. Works. At 1280 wide and 800 tall: available ~1248×~680, 72vh=576 < 1248 so canvas width=576, height 576. Hmm that's only 576² grid — too tiny? Cell ~26px on 22 grid → 576 total, cells ~26px. Fine, visible. Could instead cap height differently: `min(100vw-32px, 80vh, 700px)`. Let me do `max-width: min(100vw - 32px, 80vh)` — at 1280×800: min(1248, 640)=640 width, 640 height. Cells ~29px. Good size. At 375×800: min(343,640)=343 wide → 343 tall, cells ~16px. Fine, no scroll.
Set canvas attribute logical size: GRID=22, cellSize computed = clamp(floor(availCell/GRID), 12, 28)? availCell = min(availWidth, availHeight)/GRID. Let me compute in JS resize handler:
```
const availW = window.innerWidth - 32; const availH = window.innerHeight - 80;
cellSize = Math.max(14, Math.min(28, Math.floor(Math.min(availW,availH)/GRID)));
```
Then canvas.width=GRID*cellSize etc. CSS max-width handles display scaling so even if canvas attribute bigger than view, CSS shrinks it. Good.
Overlay positioning: place absolutely over stage, use percentage-based centering; pointer-events none so clicks pass through to start the game.
Let me structure markup:
```
<div id="app">
<header class="scoreboard">
<div class="stat"><span class="label">SCORE</span><span id="score" class="value">0</span></div>
<div class="title">NEON SNAKE</div>
<div class="stat"><span class="label">BEST</span><span id="best" class="value">0</span></div>
</header>
<div class="stage">
<canvas id="game"></canvas>
<div id="overlay" class="overlay hidden">
<div class="panel" id="panel">...</div>
</div>
</div>
</div>
```
JS toggles .hidden and content per state.
Now the JS. Let me write it carefully.
```js
const canvas = document.getElementById('game');
const ctx = canvas.getContext('2d');
const GRID = 22;
let cell = 18;
// state
const STATE = { MENU:'menu', PLAY:'play', PAUSE:'pause', OVER:'over' };
let state = STATE.MENU;
const snake = []; // [{x,y}] head last = [0]
let dir = {x:1,y:0};
let history = []; // recent accepted directions {x,y}
let nextDir = {x:1,y:0}; // buffered pending direction applied next tick
let food = {x:10,y:10};
let score = 0;
let best = 0;
let particles = [];
let started = false;
const TICK = 110; // ms per step
let lastTick = 0;
let acc = 0;
let elapsed = 0; // interp factor accumulator (separate timer)
let headRender = null; // {x,y} rendered head position for interpolation...
```
Hmm interpolation tracking. Approach: maintain `positions` per segment: each segment stores `fromX,fromY,toX,toY` updated at tick. Render each at lerp. Simpler: each segment has current pos and previous pos; on each tick step, shift all segments: seg[i].cur = seg[i+1].old (now becomes the new tail); seg[i].prev = old cur; seg[0].prev = old cur; seg[0].cur = newHead. Then render seg[i] at lerp(seg[i].prev, seg[i].cur, t).
On init: all segments from, to = same cell.
Particles update: position += vel; vel *= 0.95; alpha -= 0.02 each frame; remove when alpha<=0. Spawn ~12 particles on eat, random velocities, colors from palette.
Rough render function each frame:
```
drawGrid();
drawFood();
drawSnake(t); // interpolated
drawParticles();
```
drawSnake with rounded rects per segment with gap:
- gap = cell*0.12
- roundRect helper
- stroke width ~ cell*0.32, lineCap round
- For head: brighter fill, eyes.
- Gradient per segment maybe.
Eyes: two eyes on head, each offset from head center perpendicular + forward. Compute eye base positions, pupils offset by dir. Simple: eye centers at headCenter + dir*eyeOffset + perp*eyeW*0.5... let me: eyeBase = headCenter ± perpDir * eyeDist; pupilOffset = dir * pupilShift; eye drawn white circle radius eyeR, black pupil circle smaller shifted along dir. This gives looking-toward direction.
perpDir for dx,dy: perp={-dy,dx}.
drawFood: pulsing radius r = cell*(0.30 + 0.08*sin(time)). Glow via shadowBlur. Color: warm orange/coral or green neon. Pick color based on... fixed neon pink/cyan. Let me do vivid cyan with glow. Add small star sparkle? Keep simple.
drawGrid: lines alpha low.
Background: fill dark, plus vignette? vignette via radial gradient overlay each frame cheap. Do once per frame fine.
Text rendering on canvas for score? Or keep DOM. DOM easier & crisper. I'll use DOM scoreboard always visible (good for screenshots).
Overlays: DOM panel with title/hint. Update innerHTML based on state. Show hint text per state.
Pause/menu etc via classList.
AI function:
```
function aiStep(){
// choose dir
const cands = [{x:1,y:0},{x:-1,y:0},{x:0,y:1},{x:0,y:-1}];
const safe = [];
for(const d of cands){
const nx = snake[0].x + d.x, ny = snake[0].y + d.y;
const collides = bodyHas(nx,ny);
if(!collides && inBounds(nx,ny)) safe.push({d, dist: Math.hypot(food.x*1 - nx, food.y*1 - ny)});
}
safe.sort((a,b)=> b.dist - a.dist);
// also consider reversed options if stuck
let chosen;
if(safe.length===0){
// allow reversal of lastDir
...
} else chosen = safe[0].d;
// avoid reversing: but we already excluded? Reversal check: d == opposite(snake.dir)? We included all safe incl. reversing ones. Better exclude reversals to be safe:
...
}
```
Let me be careful: include all non-reversing safe directions only; if none, allow reversal. Reversal check: d.x===-snake.dir.x && d.y===-snake.dir.y → exclude unless truly necessary.
Implementation:
```
const rev = snake.length ? (d.x===-dir.x && d.y===-dir.y) : true; // reversing
if(!rev) safe.push({d, dist});
...
chosen: among safe sorted, pick safe[0]; else pick the reverse direction of dir with lowest dist (fallback) — could cause reversal, acceptable for AI escape.
```
Actually even simpler AI that never dies:
```
function aiStep(){
const dirs = [[1,0],[-1,0],[0,1],[0,-1]];
let best = null;
for(const [dx,dy] of dirs){
if(dx===-dir.x && dy===-dir.y && ...) {}
const nx=snake[0].x+dx, ny=snake[0].y+dy;
const ok = inBounds(nx,ny) && !bodyHas(nx,ny);
if(!ok) continue;
const dist = Math.hypot(food.x-nx, food.y-ny);
if(!best || dist<best.dist) best={dx,dy,dist};
}
// tie-ish: pick strictly nearest
pick best;
}
```
This never reverses because it checks each dir individually and excludes reverse implicitly (all 4 includes reverse; but picking the nearest among all 4 might pick reverse if... no, reverse has dist to food computed normally; could be far, so nearest wins; worst case AI walks backward for a tick which is bad but rare). To guarantee no immediate reversal: exclude the reverse of snake.dir. Add check: if (dx===-dir.x && dy===-dir.y) skip — unless it's the only option (dead end). Track whether any non-reversing option exists; if yes pick nearest non-reversing; if no, pick nearest reversed. This keeps AI mostly non-reversing but escapes when trapped. Good.
Let me finalize AI:
```
function aiStep(){
const dirs=[[1,0],[-1,0],[0,1],[0,-1]];
const rev = [[-dir.x,-dir.y]]; // normalized reverse
let safeBest=null, revBest=null;
for(const [dx,dy] of dirs){
const nx=snake[0].x+dx, ny=snake[0].y+dy;
const ok = inBounds(nx,ny) && !bodyHas(nx,ny);
if(!ok) continue;
const dist = Math.hypot(food.x-nx, food.y-ny);
const isRev = dx===-dir.x && dy===-dir.y;
if(!isRev){
if(!safeBest || dist<safeBest.dist) safeBest={dx,dy,dist};
} else {
if(!revBest || dist<revBest.dist) revBest={dx,dy,dist};
}
}
const chosen = safeBest || revBest || dirs[dirs.length-1];
applyDir(chosen.dx, chosen.dy);
}
```
Wait `chosen` may be undefined. Guard.
InBounds: x in [0,GRID) etc. Actually grid 0..GRID-1 inclusive.
bodyHas(x,y): any segment at (x,y). Note: includes tail. As discussed we check all body → correct game over.
applyDir(dx,dy): history.push(current dir?), validate non-reverse against history last; if invalid keep pending. Actually we want: attempt set snake direction to proposed; if the proposed equals opposite of last applied → ignore (keep current).
```
function tryDir(dx,dy){
// returns true if accepted
const opp = {x:-dir.x, y:-dir.y};
let accept = false;
if(dx!==dir.x || dy!==dir.y){ // different
// check against history last
const histLast = history[history.length-1];
const isRev = dx===histLast.x && dy===histLast.y;
accept = !(dx===opp.x && dy===opp.y) && !isRev; // hmm combine
}
...
}
```
Cleaner: compute `isReversingAgainstLatest = (dx===history[last].x && dy===history[last].y)`. Accept if not reversing against latest AND not reversing against previous? The previous = history[len-2]. Also accept same direction (already covered by different check — if dx==dir and dy==dir, skip). Let me write:
```
function tryDir(dx, dy){
if(dx===dir.x && dy===dir.y) return false; // no-op, ignore
const opp = {x:-dir.x, y:-dir.y};
let blocked = (dx===opp.x && dy===opp.y);
if(!blocked){
const last = history.length? history[history.length-1]: null;
if(last && dx===last.x && dy===last.y) blocked = true;
}
if(blocked) return false;
dir = {x:dx,y:dy};
history.push({x:dx,y:dy});
if(history.length>2) history.shift();
return true;
}
```
Note blocking against history-last prevents queued reversals; with history len<=2 it's effectively against last-two which safely prevents reversal even with double-keypress.
Keydown handler maps keys → directions calling tryDir; track pending via dir already (since applied immediately to `dir`), and history tracks acceptance. Fine.
But careful: if user presses two keys quickly before any tick, first sets dir+history; second checks against history-last (which is first) → rejects if reverse of first. Correct.
Also note: after human input, the next tick uses `dir`. Good.
Movement step (called on tick):
```
function step(){
const head = snake[0];
const nx = head.x+dir.x, ny=head.y+dir.y;
// wall
if(nx<0||ny<0||nx>=GRID||ny>=GRID){ returnGameOver(); }
// self
if(bodyHas(nx,ny)){ returnGameOver(); }
// move
const newHead = {x:nx,y:ny};
// push
snake.unshift(newHead);
if(bodyHas(food.x,food.y)){
// eat: don't pop tail → grow
spawnParticles(food);
score += 10;
updateBest();
placeFood();
} else {
snake.pop();
}
}
```
bodyHas includes tail; when moving head to tail cell (not eating) it triggers game over — as decided acceptable.
Tick scheduling: in RAF loop, if state===PLAY: acc+=dt; if acc>=TICK: acc=0; step(); elapsed=acc; (recompute interp t from acc/TICK). Rendering t = clamp(acc/TICK,0,1).
Interpolated render uses positions. Maintain seg positions: at step(), set seg[i].to=seg[i].cur; for each i shift: seg[i].cur=seg[i+1].to (old); seg[i].to=that; seg[0].to=newHead. Hmm careful ordering.
Simpler interpolation: store headPrev and headCur; body segments move along head path. Each segment's rendered position = headCur - (i)*unitVectorToSegment * cell*renderT... approximate: segment i is i cells behind head along snake axis. With slight perpendicular variation. Honestly, simplest good-looking approach: draw each segment at position = curCell, but apply an offset based on interpolation: draw segment i at (lerp(prevPos, curPos, t)) where prevPos and curPos advance along their own axes.
Implementation with prev/cur pairs per segment:
```
// at tick:
// compute newPositions array
// moveSnake function:
function moveSnake(){
const head = snake[0];
const nx = head.x+dir.x, ny=head.y+dir.y;
const nh = {x:nx,y:ny};
// prev/cur for all
const nseg = [];
for(let i=0;i<snake.length;i++){
const s = snake[i];
nseg.push({from:s.from, to:s.to, x:s.x, y:s.y});
}
nseg.unshift({from:nh, to:nh, x:nh.x, y:nh.y}); // head new
// body: copy old positions then shift
const oldBody = snake.map(s=>({from:s.from,to:s.to}));
oldBody.forEach(o=>{ /* shift */ });
...
}
```
This is getting messy. Alternative cleaner approach: Don't store from/to; instead render with offset = t * cell in direction. Since every segment moves in straight line from prev to next by exactly one cell per tick in its local direction... but segments are orthogonal, offsets compound. For per-segment world offset: seg i's movement vector = (seg[i].dir) per tick... we don't store per-segment dir.
Simplest correct visual: store positions as array and also store `prev` snapshot before moving. Let me store `snake` as objects {x,y,fx,fy} where fx,fy = previous cell. At tick, snapshot:
```
function tick(){
const head = snake[0];
const nx = head.x+dir.x, ny=head.y+dir.y;
// check collisions (including tail)
const nh = {x:nx,y:ny, fx:head.x, fy:head.y};
// collisions
if(bad(nh)) returnGameOver();
// shift body
for(let i=snake.length-1;i>=0;i--){
const s=snake[i];
s.fx=s.x; s.fy=s.y;
s.x=nx; s.y=ny;
}
// hmm shifting wrong order
}
```
Careful: iterate front to back? Let me think: snake[0] becomes new head; snake[1] moves to old head; ... snake[last] becomes old tail (or removed if eaten).
Track:
```
const nh = {...head, fx:head.x, fy:head.y}; // first of new body, has previous pos
const nseg = [nh];
for(let i=1;i<snake.length;i++){
const s=snake[i];
nseg.push({x:s.x, y:s.y, fx:s.x, fy:s.y, dir:s.dir});
}
```
and to make tail move (not eaten), replace last: nseg[nseg.length-1] stays (will pop from array). For eaten, tail stays so array grows by 1. Either way nseg length: if not eaten, nseg.length = snake.length (pop last). If eaten, +1.
Store nseg back into snake (keeping fx,fy fields). dir field: compute dir per segment = direction from this segment's old position to previous segment's old position. After building, assign. For rendering sliding, store dir too for smooth segment slide: render seg at lerp(pos, pos+dir*cell, t)?? Hmm pos+dir*cell one cell further — but pos already advanced by one cell at tick. So new pos IS old pos + dir. To interpolate visually, we want drawn position = lerp(oldPos, newPos, t). Since newPos = oldPos + dir*cell, that equals oldPos + dir*cell*t.
So each segment stores x,y, dir. Rendered = x + dir.x*cell*t, y+dir.y*cell*t. But edges? During first half of tick it slides from old to new — matches exactly when t goes 0→1 across one tick.
For the head, head previous = nh.fx,nh.fy = current head pos. new head = nh. new head rendered at lerp(nh.fx, nh.x, t) = nh + (nh-nh.fx)*t = nh + dir*cell*t since nh.x-nh.fx=dir*cell. Same formula.
Render loop:
```
for(let i=snake.length-1;i>=0;i--){ // draw back to front
const s=snake[i];
const tx = s.x + s.dir.x*cell*t;
const ty = s.y + s.dir.y*cell*t;
drawSegment(tx,ty, i===0);
}
```
Store dir at tick building. Compute for i>=1: dir = unit(oldPos_next - oldPos_this)... but since segments move purely along chain... Actually simpler: for i>=1, old pos = s.fx,s.fy; new pos after tick = s.x,s.y; dir = normalize(new-old). And for head i=0: dir = unit(nh - headOldPos) where headOldPos = snake[0].x,y. nh.x=head.x+dir.x; nh.fx=head.x → dir same as global dir. Compute uniformly:
When building nseg:
```
for(let i=0;i<snake.length;i++){
const s=snake[i];
const dx=s.x - s.fx, dy=s.y - s.fy; // = dir per segment (unit? cell=1)
// per-segment dir
}
```
Check: at tick time, before changing positions, fx=old x, fy=old y. s.x=new, s.y=new. dx = dir (one cell). For head i=0, dir per-segment = global dir. Consistent! So store segment.dir = {x:s.x-s.fx, y:s.y-s.fy} then divide by cell (cell=1 in grid units; we use cell units later for pixel math). In grid coords each step = 1 unit.
So render in pixel space: tx = (s.x + s.dir.x)*cell*t etc. where s.dir in grid units (e.g., 1/-1/0).
That gives perfect sliding. No persistence issues.
particles: array of {x,y,vx,vy,life,maxLife,color,size}. update each frame: x+=vx; y+=vy; vy+=0.15(gravity mild); life-=dt; draw alpha=life/maxLife. spawn at food pixel pos with random angles.
Now rendering styles. Let me design:
Colors:
- bg deep: #070b1a
- grid lines: rgba(255,255,255,0.035)
- snake body: gradient teal→green; head: bright #7CFFB2 with darker outline; eyes white + dark pupils.
- food: #FF4D8B coral/magenta with glow, plus pulse ring.
- accent text: #9FEFFF.
Particles on eat: mix of magenta, gold, cyan fades.
UI text: "SCORE 123", "BEST 25", title NEON SNAKE. Use bold letter spacing.
Overlays:
- MENU: big title, subtitle "Click or press a key to start", controls hint (Arrows/WASD), best score. Start button auto-click.
- PAUSE: "PAUSED", "Press P to resume · SPACE/ENTER to quit to menu".
- OVER: "GAME OVER", final score, best, "SPACE / ENTER to restart", "P to pause".
Overlay styling: translucent blur backdrop, panel card.
Restart function: resetScore(), resetSnake(), placeFood(), state=PLAY, started=true, hide overlay, lastTick=Date.now().
Init: place food, build snake in middle, state=MENU, started=false. Demo: when started, state transitions PLAY and AI steps begin.
Wait — "Games must start in a self-playing demo mode ... behind a 'Click or press a key to start' message." So on start, just play with AI. Done.
P key: if PLAY→PAUSE overlay; if PAUSE→PLAY. If MENU/OVER maybe also toggles? Keep: PLAY↔PAUSE; other states ignores.
Space/Enter: from OVER→restart; from PAUSE→resume; from MENU→start. Implement accordingly.
Click handler: any click → started=true, if MENU/OVER → startGame(). Also keydown: if !started return; process.
Note: "The iframe only receives keyboard events after the user clicks it" — our click sets started. Good. But also mouse clicks during game shouldn't do weird things; fine.
Console errors: guard everything. Avoid null refs. Use constants.
Edge: best score persistence — in-memory only (reset on reload). OK per constraints (localStorage blocked).
Let me also add subtle background animation even in menu: floating particles in bg? Could draw slowly drifting dots behind canvas for life. Keep minimal: gentle pulsing of food glow via time accumulation in RAF regardless of state. In menu, still render food + snake idle animation (snake gently animating? static). Let me just animate food pulse and a faint grid shimmer always; snake static in menu. Add ambient floating particles in bg outside canvas via CSS? Could do radial gradients. Skip; keep focus on canvas.
Actually to satisfy "Something meaningful must be visible on first paint: a screenshot is taken automatically about 3 seconds after load, without any interaction" — the menu overlay with title + instructions + snake + food visible counts as meaningful content. Good. Also ensure nothing waits long.
Now let me also handle high-DPI: set ctx.scale(dpr,dpr) on resize where dpr = devicePixelRatio capped at 2. Logical canvas = GRID*cell px; CSS size via max-width. With dpr scaling, canvas.width=GRID*cell*dpr; ctx.setTransform(dpr,0,0,dpr,0,0). Then draw in logical px. Careful with resizing: recompute cell, set width, setTransform.
Resize handling:
```
function resize(){
const availW = window.innerWidth - 32;
const availH = window.innerHeight - 90; // header overhead
cell = Math.max(14, Math.min(28, Math.floor(Math.min(availW,availH)/GRID)));
const px = GRID*cell;
const dpr = Math.min(2, window.devicePixelRatio||1);
canvas.width = px*dpr; canvas.height = px*dpr;
ctx.setTransform(dpr,0,0,dpr,0,0);
}
window.addEventListener('resize', resize);
resize();
```
But max-width CSS also limits display. At 1280×800: availW=1248, availH=710, min/22=32.7→cell min(28)=28, px=616. CSS max-width min(1248, 80vh=640) → display 640 wide. Slight upscale. Fine.
At 375×800: availW=343,availH=710, min/22=15.6→cell=15, px=330. CSS max-width min(343,640)=343 → displays ~343 wide, cells ~15.6px. Crisp enough with dpr. Good, no horizontal scroll since body overflow hidden.
Header scoreboard height ~60px. Fine.
Let me write roundRect helper or use ctx.roundRect (supported modern Chrome). Chrome 99+ supports ctx.roundRect. Safe to use; fallback manual if needed. I'll use ctx.roundRect with fallback.
Let me now write the complete file. I'll be thorough.
One more: "smooth movement" — our interpolation handles it. Also maybe speed up slightly as score grows for fun? Optional. Keep constant TICK for predictability. Could add speed ramp — nice touch. I'll add modest ramp: TICK decreases from 130 to ~75 as score increases, keeping challenge. That's a nice polish. Cap min 70.
Let me code it all now.
Also handle `keydown` repeat: ignore repeats (e.repeat) to avoid double steps? It won't double-step since steps are time-based. Repeats just call tryDir again harmlessly. Fine but could queue rapid-fire inputs: with history blocking reversals it's OK. Add e.repeat skip.
Prevent default for arrows/space to stop page scroll — body overflow hidden anyway; still call preventDefault.
Alright, writing final code:
```html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>Neon Snake</title>
<style>
:root{
--bg:#060912;
--panel:rgba(14,20,38,0.92);
--accent:#36f1c5;
--accent2:#ff5fa2;
--text:#e9eff8;
--muted:#8aa0c4;
}
*{margin:0;padding:0;box-sizing:border-box;-webkit-tap-highlight-color:transparent;}
html,body{height:100%;background:var(--bg);overflow:hidden;font-family:"Segoe UI",system-ui,-apple-system,"Helvetica Neue",Arial,sans-serif;color:var(--text);}
#app{position:fixed;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;padding:14px;gap:14px;}
.scoreboard{display:flex;align-items:center;gap:18px;width:100%;max-width:680px;justify-content:space-between;padding:10px 14px;border-radius:14px;background:linear-gradient(180deg,rgba(40,55,90,.55),rgba(20,28,50,.55));backdrop-filter:blur(8px);border:1px solid rgba(255,255,255,.06);}
.stat{display:flex;flex-direction:column;align-items:center;line-height:1.1;}
.label{font-size:10px;letter-spacing:3px;color:var(--muted);text-transform:uppercase;}
.value{font-size:20px;font-weight:700;font-variant-numeric:tabular-nums;color:var(--text);}
.spacer{flex:1;}
.brand{font-weight:800;letter-spacing:4px;font-size:18px;color:var(--accent);text-shadow:0 0 18px rgba(54,241,197,.35);}
.stage{position:relative;display:flex;align-items:center;justify-content:center;width:100%;}
canvas{display:block;max-width:min(100vw-28px,84vh);height:auto;border-radius:16px;background:#0b1120;box-shadow:0 0 0 1px rgba(255,255,255,.05),0 22px 60px rgba(0,0,0,.55),inset 0 0 60px rgba(0,0,0,.5);touch-action:none;}
.overlay{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;pointer-events:none;border-radius:16px;}
.overlay.hidden{display:none;}
.panel{pointer-events:auto;background:var(--panel);border:1px solid rgba(54,241,197,.25);border-radius:18px;padding:30px 36px;text-align:center;box-shadow:0 20px 60px rgba(0,0,0,.6),inset 0 0 30px rgba(54,241,197,.06);backdrop-filter:blur(10px);max-width:90vw;animation:pop .25s ease;}
@keyframes pop{from{transform:translateY(8px) scale(.96);opacity:0;}to{transform:none;opacity:1;}}
.big{font-size:34px;font-weight:800;letter-spacing:6px;margin-bottom:6px;}
.small{font-size:13px;color:var(--muted);letter-spacing:1px;margin:10px 0 16px;}
.key{display:inline-block;padding:3px 9px;border-radius:7px;background:rgba(255,255,255,.08);border:1px solid rgba(255,255,255,.14);font-size:12px;color:var(--text);letter-spacing:1px;}
.hint{font-size:13px;color:var(--text);background:rgba(54,241,197,.1);border:1px solid rgba(54,241,197,.25);padding:8px 14px;border-radius:10px;letter-spacing:1px;}
@media(max-width:520px){
.big{font-size:26px;letter-spacing:4px;}
.scoreboard{gap:10px;padding:8px 10px;}
.value{font-size:17px;}
.brand{font-size:15px;letter-spacing:3px;}
.panel{padding:22px 22px;}
}
</style>
</head>
<body>
<div id="app">
<header class="scoreboard">
<div class="stat"><span class="label">SCORE</span><span id="score" class="value">0</span></div>
<div class="brand">NEON SNAKE</div>
<div class="stat"><span class="label">BEST</span><span id="best" class="value">0</span></div>
</header>
<div class="stage">
<canvas id="game"></canvas>
<div id="overlay" class="overlay">
<div class="panel" id="panel"></div>
</div>
</div>
</div>
<script>
(()=>{
const canvas=document.getElementById('game');
const ctx=canvas.getContext('2d');
const scoreEl=document.getElementById('score');
const bestEl=document.getElementById('best');
const overlay=document.getElementById('overlay');
const panel=document.getElementById('panel');
const GRID=22;
const TICK_MAX=130, TICK_MIN=70;
let cell=18, DPR=1;
const STATE={MENU:'menu',PLAY:'play',PAUSE:'pause',OVER:'over'};
let state=STATE.MENU;
let snake=[]; let dir={x:1,y:0}; let history=[];
let food={x:10,y:10};
let score=0, best=0, foodTimer=0, fpsT=0, timeAcc=0;
let particles=[];
let started=false, startedAnim=false;
let acc=0, dtAcc=0, now=0;
let shake=0;
const COLORS={body1:'#1f8f72',body2:'#33e3a8',head:'#7bffe0',headEdge:'#a8ffe6',eyes:'#ffffff',pupil:'#0b1220',food:'#ff5fa2',foodGlow:'#ffd36b'};
// ---- helpers ----
const rand=(a,b)=>a+Math.random()*(b-a);
const clamp=(v,a,b)=>Math.max(a,Math.min(b,v));
function inBounds(x,y){return x>=0&&y>=0&&x<GRID&&y<GRID;}
function bodyHas(x,y,excludeTail){
for(let i=0;i<snake.length;i++){
if(excludeTail && i===snake.length-1) continue;
if(snake[i].x===x&&snake[i].y===y) return true;
}
return false;
}
function placeFood(){
const free=[];
for(let y=0;y<GRID;y++)for(let x=0;x<GRID;x++){ if(!snake.some(s=>s.x===x&&s.y===y)){free.push({x,y});} }
food=free[Math.floor(rand(0,free.length))]||{x:10,y:10};
}
// ---- AI ----
function aiStep(){
const dirs=[[1,0],[-1,0],[0,1],[0,-1]];
let safeBest=null, revBest=null;
for(const [dx,dy] of dirs){
const nx=snake[0].x+dx, ny=snake[0].y+dy;
const ok=inBounds(nx,ny)&&!bodyHas(nx,ny,true);
if(!ok) continue;
const dist=Math.hypot(food.x-nx,food.y-ny);
const isRev=dx===-dir.x&&dy===-dir.y;
if(!isRev){ if(!safeBest||dist<safeBest.dist) safeBest={dx,dy,dist}; }
else { if(!revBest||dist<revBest.dist) revBest={dx,dy,dist}; }
}
const chosen=(safeBest||revBest)||dirs[dirs.length-1];
tryDir(chosen.dx,chosen.dy);
}
// ---- snake control ----
function tryDir(dx,dy){
if(dx===dir.x&&dy===dir.y) return false;
const opp={x:-dir.x,y:-dir.y};
let blocked=(dx===opp.x&&dy===opp.y);
if(!blocked){ const last=history[history.length-1]; if(last&&(dx===last.x&&dy===last.y)) blocked=true; }
if(blocked) return false;
dir={x:dx,y:dy};
history.push({x:dx,y:dy}); if(history.length>2) history.shift();
return true;
}
function step(){
const head=snake[0];
const nx=head.x+dir.x, ny=head.y+dir.y;
if(!inBounds(nx,ny)||bodyHas(nx,ny)){ returnGameOver(); return; }
const nh={x:nx,y:ny,fx:head.x,fy:head.y};
const nseg=[];
for(let i=0;i<snake.length;i++){
const s=snake[i];
const d=i===0? {x:dir.x,y:dir.y}:{x:(s.x-s.fx),y:(s.y-s.fy)};
nseg.push({...s,fx:s.fx,fy:s.fy,dir:d});
}
nseg.unshift(nh);
snake=nseg;
if(bodyHas(food.x,food.y)) spawnParticles(food.x*cell+cell/2, food.y*cell+cell/2);
score+=10;
if(score>best){best=score; bestEl.textContent=best;}
scoreEl.textContent=score;
updateTickSpeed();
placeFood();
}
function updateTickSpeed(){ const k=1-(score/300); const t=TICK_MAX+(TICK_MIN-TICK_MAX)*(k); TICK_VAR=clamp(t,TICK_MIN,TICK_MAX); }
let TICK_VAR=TICK_MAX;
function spawnParticles(gx,gy){
const n=14;
for(let i=0;i<n;i++){
const a=rand(0,Math.PI*2), sp=rand(1.2,4.2);
const cols=['#ff5fa2','#ffd36b','#7bffe0','#36f1c5'];
particles.push({x:gx,y:gy,vx:Math.cos(a)*sp,vy:Math.sin(a)*sp-1.2,life:rand(0.5,1.0),max:1,size:rand(2.2,4.6),col:cols[(Math.random()*cols.length)|0]});
}
}
// ---- game flow ----
function resetGame(){
snake=[{x:10,y:10},{x:10,y:11},{x:10,y:12}];
dir={x:1,y:0}; history=[]; history.push({x:1,y:0}); // initial dir history of one
score=0; scoreEl.textContent='0';
food={x:10,y:11}; placeFood();
particles=[]; acc=0;
state=STATE.PLAY; hideOverlay(); started=true;
acc=performance.now()-Math.min(performance.now(),100);
}
function startGame(){ resetGame(); }
function returnGameOver(){ state=STATE.OVER; shake=14; showOverlay(overPanel()); }
function togglePause(){ if(state===STATE.PLAY){state=STATE.PAUSE;showOverlay(pausePanel());} else if(state===STATE.PAUSE){state=STATE.PLAY;hideOverlay();} }
function showOverlay(html){ panel.innerHTML=html; overlay.classList.remove('hidden'); }
function hideOverlay(){ overlay.classList.add('hidden'); }
function pausePanel(){return `<div class="big" style="color:var(--accent)">PAUSED</div><div class="small">Game paused. Press <span class="key">P</span> to resume.</div><div class="hint">SPACE / ENTER resumes · the demo continues behind this screen</div>`;}
function overPanel(){return `<div class="big" style="color:var(--accent2)">GAME OVER</div><div class="small">Final score ${score}</div><div class="small">Best ${best}</div><div class="hint">SPACE / ENTER to restart · P to pause</div>`;}
function menuPanel(){return `<div class="big" style="color:var(--accent)">NEON SNAKE</div><div class="small">A self-playing demo. An AI guides you.</div><div class="small">Controls</div><div class="key">↑ ↓ ← →</div><div class="key">W A S D</div><div class="key">SPACE / ENTER start</div><div class="small">Best ${best}</div><div class="hint">Click or press a key to start</div>`;}
// ---- input ----
const KEYMAP={ArrowUp:[0,-1],ArrowDown:[0,1],ArrowLeft:[-1,0],ArrowRight:[1,0],w:[0,-1],s:[0,1],a:[-1,0],d:[1,0],W:[0,-1],S:[0,1],A:[-1,0],D:[1,0]};
window.addEventListener('keydown',e=>{
if(!started) return; // requires click first
if(e.key===' '||e.key==='Enter'){ e.preventDefault(); if(state===STATE.OVER||state===STATE.MENU){startGame();return;} if(state===STATE.PAUSE){togglePause();return;} return; }
if(e.key==='p'||e.key==='P'){ e.preventDefault(); togglePause(); return; }
const m=KEYMAP[e.key];
if(m){ e.preventDefault(); tryDir(m[0],m[1]); }
});
window.addEventListener('click',()=>{ started=true; if(state===STATE.MENU||state===STATE.OVER||state===STATE.PAUSE){ startGame(); } });
// Also keydown could start without click? spec says after click. Keep require click.
// ---- rendering ----
function rr(x,y,w,h,r){ ctx.beginPath(); ctx.roundRect(x,y,w,h,r); }
function roundRectPath(x,y,w,h,r){ ctx.beginPath(); if(ctx.roundRect){ctx.roundRect(x,y,w,h,r);} else {ctx.rect(x,y,w,h);} }
function drawGrid(){
ctx.clearRect(0,0,GRID*cell,GRID*cell);
ctx.fillStyle='#0b1120';
ctx.fillRect(0,0,GRID*cell,GRID*cell);
ctx.strokeStyle='rgba(120,160,220,0.05)';
ctx.lineWidth=1;
for(let i=0;i<=GRID;i++){ ctx.beginPath(); ctx.moveTo(i*cell,0); ctx.lineTo(i*cell,GRID*cell); ctx.stroke(); ctx.beginPath(); ctx.moveTo(0,i*cell); ctx.lineTo(GRID*cell,i*cell); ctx.stroke(); }
// subtle vignette
const g=ctx.createRadialGradient(GRID*cell/2,GRID*cell/2,GRID*cell*0.2,GRID*cell/2,GRID*cell/2,GRID*cell*0.75);
g.addColorStop(0,'rgba(255,255,255,0)'); g.addColorStop(1,'rgba(0,0,0,0.35)');
ctx.fillStyle=g; ctx.fillRect(0,0,GRID*cell,GRID*cell);
}
function drawFood(t){
const cx=food.x*cell+cell/2, cy=food.y*cell+cell/2;
const pulse=0.5+0.5*Math.sin(timeAcc*0.06);
const r=cell*(0.30+0.05*pulse);
// outer glow ring
ctx.save();
ctx.shadowColor=COLORS.foodGlow; ctx.shadowBlur=22*pulse+10;
ctx.fillStyle=COLORS.food;
ctx.beginPath(); ctx.arc(cx,cy,r,0,Math.PI*2); ctx.fill();
// inner highlight
ctx.shadowBlur=0;
ctx.fillStyle='rgba(255,255,255,0.85)';
ctx.beginPath(); ctx.arc(cx-r*0.25,cy-r*0.25,r*0.34,0,Math.PI*2); ctx.fill();
ctx.restore();
// sparkle ticks
ctx.strokeStyle='rgba(255,255,255,0.5)'; ctx.lineWidth=2;
for(let i=0;i<4;i++){ const a=timeAcc*0.05+i*Math.PI/2; const rr2=r+cell*0.2; ctx.beginPath(); ctx.moveTo(cx+Math.cos(a)*rr2,cy+Math.sin(a)*rr2); ctx.lineTo(cx+Math.cos(a)*(rr2+5),cy+Math.sin(a)*(rr2+5)); ctx.stroke(); }
}
function drawSnake(t){
const len=snake.length;
for(let i=len-1;i>=0;i--){
const s=snake[i];
const tx=(s.x+s.dir.x*cell*t)*cell; // hmm units: s.dir in grid units, cell in px; multiply cell then *cell??
...
}
}
```
Wait units confusion. Let me define clearly:
- Grid coordinates s.x,s.y in units 0..GRID-1.
- cell = px per unit.
- Movement per step = 1 unit.
- Render position px = (gridCoord + dirUnits*t) * cell.
So:
```
const gx=(s.x+s.dir.x*t)*cell, gy=(s.y+s.dir.y*t)*cell;
```
where s.dir ∈ {-1,0,1}. dir for head = dir global (1 unit). For body i>0: dir=(new-old)/cell = (s.x-s.fx)/1... wait s.x-s.fx is in grid units (both are grid coords), so s.dir in grid units. Good. Multiply (gridCoord+dirUnits*t)*cell → px.
drawSegment(gx,gy,isHead):
- r = cell*0.36
- body fill gradient per index: head bright, tail darker. Use linear gradient from head color to body color by i/(len-1).
- rounded rect with gap cell*0.16 between segments.
- gap handled by drawing segments with inner inset.
Approach: draw each segment as rounded rect of size cell*0.72, centered slightly back? Simpler: draw rounded rect at (gx - cell*0.36 + cell*0.04 ... ) eh.
Let me do: segSize = cell*0.78; inset = cell*0.12 (gap); round r = cell*0.34. Center of each seg at gx,gy. So rect: x=gx-cell*0.39, y=gy-cell*0.39, w=cell*0.78, h=cell*0.78, r=cell*0.36. The gap between segments visible (0.78*2-2*0.39≈0.18*cell < cell, small gap ok... actually 0.78 wide centered, half 0.39, neighbors also half 0.39 → they touch; gap=0.0). To get gap, inset 0.12 each side → segWidth 0.76 with overlap? Overlap looks bad. Use segSize=0.70, half=0.35, gaps: spacing needed 0.70+0.14=0.84 > 1? grid 1 unit. Too tight. Segments share cells normally; visual snake typically packed cells with rounded corners carving gaps. Packed rounded squares look great. So segSize=0.86, half=0.43 → touching, rounded corners create natural gap look. That's classic and pretty. Use that. rounded radius = half*0.7.
Colors: per-index brightness: i===0 head color bright; else lerp head→body by i/(len-1).
Eyes on head: compute eye positions:
- headCenter = gx+cell/2, gy+cell/2
- forwardF = dir.x, dir.y (global dir)
- perp = {-dir.y, dir.x}
- eyeDist = cell*0.32; eyeOffset cell*0.18; eyeR=cell*0.20
- eyeL = {x:headCenter.x - dir.x*cell*0.5 - perp.x*cell*eyeDist + ... } let me just: eyeL = headCenter + dir*0.32*cell + perp*0.26*cell; eyeR = headCenter + dir*0.32*cell - perp*0.26*cell. Pupils offset along dir by 0.10*cell.
Draw white circle r=eyeR at eye, then black pupil r=eyeR*0.6 shifted toward dir by 0.06*cell. Dark pupil sits on bottom of eye? Eyes usually half-visible; draw eye clipped by head edge — overkill. Simple two white circles with pupils, head drawn as circle covering? Head drawn as rounded rect covering eyes partially: with segSize 0.86 and eyeOffset inside, fine.
Add subtle outline/glow on head: shadowBlur 10, shadowColor accent.
Particles draw: circles with alpha life/max, additive? just normal with alpha.
Render loop:
```
function frame(ts){
now=ts;
const dt=(ts-lastTime)/1000; lastTime=ts;
timeAcc+=dt;
// menu idle anim: animate food pulse always
if(state===STATE.PLAY||state===STATE.MENU){
acc+=dt*1000;
while(acc>=TICK_VAR){ acc-=TICK_VAR; step(); }
if(state===STATE.PLAY) t=acc/TICK_VAR;
} else {
// gentle interp for menu snake (head moves sideways? static)
t=0.0? ...
}
particles update with dt
shake*=0.9;
draw();
requestAnimationFrame(frame);
}
```
For menu state, snake static (t=0, draws at grid positions). Food pulses. Nice. During OVER, freeze t (snake frozen), show overlay. During PAUSE overlay shows but logic paused — still render last frame under overlay? We can render with t=0 (frozen) — fine, overlay covers.
Compute t each frame:
- PLAY: t = clamp(acc/TICK_VAR,0,1)
- others: t=0 (static; could animate slight bob but skip)
Particles drift always (even in menu? no—only during play). Stop updating when not playing.
Update particles in frame always? Only when playing. In menu there are none. OK update always with dt but they're empty in menu.
Let me write frame:
```
let lastTime=performance.now();
function frame(ts){
const dt=Math.min((ts-lastTime)/1000,0.05); lastTime=ts;
timeAcc+=dt;
if(state===STATE.PLAY){
acc+=dt*1000;
while(acc>=TICK_VAR){ acc-=TICK_VAR; step(); }
t=clamp(acc/TICK_VAR,0,1);
} else t=0;
// particles
for(let i=particles.length-1;i>=0;i--){
const p=particles[i];
p.x+=p.vx; p.y+=p.vy; p.vy+=0.18; p.life-=dt;
if(p.life<=0) particles.splice(i,1);
}
shake*=0.88;
draw();
requestAnimationFrame(frame);
}
```
Need t defined globally. Declare `let t=0;`
Edge: acc huge on tab switch — clamp dt used. while could spin fast but guarded.
Now draw():
```
function draw(){
ctx.save();
if(shake>0.3){ ctx.translate(rand(-shake,shake),rand(-shake,shake)); }
drawGrid();
drawFood(t);
drawSnake(t);
drawParticles();
ctx.restore();
}
```
drawParticles:
```
for(const p of particles){
const a=clamp(p.life/p.max,0,1);
ctx.globalAlpha=a;
ctx.fillStyle=p.col;
ctx.beginPath(); ctx.arc(p.x,p.y,p.size,0,Math.PI*2); ctx.fill();
}
ctx.globalAlpha=1;
```
drawSnake:
```
const len=snake.length;
for(let i=len-1;i>=0;i--){
const s=snake[i];
const gx=(s.x+s.dir.x*t)*cell, gy=(s.y+s.dir.y*t)*cell;
const sz=cell*0.86, hr=cell*0.40;
const r=hr*0.62;
// color lerp
const k=len>1?(i/(len-1)):0;
const col = i===0? headColor : lerpColor(headColor, bodyDark, k);
ctx.save();
// glow
if(i===0){ ctx.shadowColor=COLORS.headEdge; ctx.shadowBlur=14; }
roundRectPath(gx-sz/2+cell*0.03, gy-sz/2+cell*0.03, sz-cell*0.06, sz-cell*0.06, r);
ctx.fillStyle=col; ctx.fill();
ctx.shadowBlur=0;
// eyes
if(i===0){
const cx=gx+cell*0.03, cy=gy+cell*0.03;
const fwd={x:s.dir.x,y:s.dir.y}, perp={-fwd.y,fwd.x};
const ed=cell*0.34, ew=cell*0.20, off=cell*0.20;
const exL={x:cx-fwd.x*cell*0.5+perp.x*off, y:cy-fwd.y*cell*0.5+perp.y*off};
...
}
ctx.restore();
}
```
Let me simplify eyes:
```
if(i===0){
const cx=(gx+cell/2)+cell*0.03, cy=(gy+cell/2)+cell*0.03;
const fw={x:s.dir.x,y:s.dir.y};
const pr={x:-fw.y,y:fw.x};
const d=cell*0.36, o=cell*0.20, er=cell*0.21;
const el={x:cx+fw.x*d+pr.x*o, y:cy+fw.y*d+pr.y*o};
const er2={x:cx+fw.x*d-pr.x*o, y:cy+fw.y*d-pr.y*o};
// white eyes
ctx.fillStyle='#ffffff';
circle(el.x,el.y,er); ctx.fill();
circle(er2.x,er2.y,er); ctx.fill();
// pupils
ctx.fillStyle='#0b1220';
circle(el.x+fw.x*cell*0.08, el.y+fw.y*cell*0.08, er*0.55); ctx.fill();
circle(er2.x+fw.x*cell*0.08, er2.y+fw.y*cell*0.08, er*0.55); ctx.fill();
}
```
where circle(x,y,r)=beginPath arc fill.
lerpColor(c1,c2,k): parse hex → rgb → lerp. Helper:
```
function hex2rgb(h){h=h.replace('#','');return [parseInt(h.substr(0,2),16),parseInt(h.substr(2,2),16),parseInt(h.substr(4,2),16)];}
function lerpColor(a,b,k){const A=hex2rgb(a),B=hex2rgb(b);return `rgb(${Math.round(A[0]+(B[0]-A[0])*k)},${Math.round(A[1]+(B[1]-A[1])*k)},${Math.round(A[2]+(B[2]-A[2])*k)})`;}
```
Define headColor=COLORS.head, bodyDark=COLORS.body1. Body gradient head→body1 by k. Good.
UI scoreboard update on eat happens in step(). Good. bestEl update also. Initial bestEl text '0'.
Init call:
```
bestEl.textContent=best; // 0
showOverlay(menuPanel());
requestAnimationFrame(frame);
```
Wait best initialized 0, score 0. Set display. Also resetGame already? No—init separately. Let me init: snake empty? Better build initial snake on first paint for nicer screenshot: build snake middle, place food, but DON'T step (menu state, t=0 static-ish, food pulse via timeAcc). Actually menu "hint" says start by click. Static snake + pulsing food is fine and meaningful. Build on init.
Let me restructure init:
```
buildInitialSnake(); bestEl.textContent='0'; showOverlay(menuPanel());
requestAnimationFrame(frame);
```
buildInitialSnake: snake 3 cells vertical, food placed. dir not relevant (menu no movement).
But frame loop only steps when PLAY; menu draws static. Food pulse via timeAcc independent of step. Good.
When resetGame called: rebuild snake, placeFood, state PLAY.
Also need to reset score display etc. Done in resetGame.
Also handle: when game over overlay shown, FPS-ish nothing needed.
One concern: `history.push({x:1,y:0})` initial in resetGame — fine.
Another: tryDir called by AI and human; AI also called every tick. During PLAY we call aiStep each tick regardless of human input (demo mode). That means human can't override mid-tick? Keydowns store in dir immediately (applied at next tick). Since AI decides each tick and overrides human's applied dir? Wait: AI calls tryDir which sets dir if accepted, else leaves dir. But human could press a key that gets ignored (reverse attempt) — dir unchanged by human, AI still chooses. However human press BEFORE AI tick: AI picks a dir replacing human dir — human input lost! That breaks human control.
Fix: split into "human override" buffer. Standard: keep humanQueue of pending dirs; at each tick, first process human queue (set dir+history), THEN AI evaluates. Let me implement: humanDirQueue = []; on keydown push {dx,dy} (without tryDir call); at tick (in step sequence), drain queue calling tryDir until empty; then run AI. Actually AI should pick among safe directions avoiding reverse of CURRENT dir (which may have been changed by human).
Redo:
```
let humanQ=[];
// keydown: push to humanQ, but only if not blocked... we still tryDir? We want to reject reversals for human too. Calling tryDir on push attempts immediate application but then AI overwrites. So just push raw {dx,dy} for human; during drain call tryDir (which validates reversals). If rejected, drop from queue? Rejected means invalid for human too. Let me push and let tryDir validate; keep only valid entries.
```
Simplify: keydown → tryDir(dx,dy) immediately (sets dir if valid). If human valid, dir changed immediately — fine since human input is authoritative. Problem: AI next tick overrides dir. So AI must respect a "claimed human dir" — keep humanDir reference = most recent human-accepted dir, and AI avoids reversing that unless forced, and only picks among directions also safe... but if human turned, AI shouldn't undo human's intended direction blindly.
Cleanest: dequeue model. Human requests stored; AI only acts when no human intent for current tick? Overkill.
Pragmatic: At each tick, FIRST apply queued human inputs (process queue, each tryDir against current state — these win), THEN run AI. Store humanQ as entries; clear entries when consumed? Since step() happens once per TICK, queue drained inside step before AI. Queue could contain stale entries from previous frames; that's fine—they get processed at next tick. But multiple ticks could pass: queue drained at first tick after arrival, leaving subsequent entries forever — those are from old presses, harmless (reversal-checked). Actually if entry is valid (not reverse), it WILL change dir; leftover entries might be reverse of something → rejected. OK.
But careful: if human presses 3 keys before any tick, queue has 3, drained at next tick: first applies dir1, second tryDir checks against dir1 (history) → if reverse, dropped; third similar. So queue effectively filters reversals at consumption. Good. Even simpler than immediate tryDir filtering. Let me do that.
Implementation:
```
function drainHuman(){ let kept=true; while(kept){ kept=false; for(let i=humanQ.length-1;i>=0;i--){const e=humanQ[i]; tryDir(e.dx,e.dy); if(!tryDir(...)) ...} } }
```
messy. Do:
```
function drainHuman(){
humanQ.forEach(e=>tryDir(e.dx,e.dy));
humanQ=[];
}
```
TryDir returns boolean; ignore result, but invalid entries remain in queue (harmless, they'd try again at next tick and fail). Fine—just consume all next tick. Slight redundant work, negligible.
Move tryDir drain into step BEFORE AI:
```
function step(){
drainHuman();
// collisions...
if(gameOver condition) returnGameOver(); return;
...movement...
aiStep(); // AI respects current dir (post-human)
...
}
```
Order: human inputs apply (can change dir), then move head along dir, then AI set direction for NEXT tick based on post-human dir. AI then naturally avoids reversing into the updated dir.
But subtle: AI computing candidates uses snake[0]+dir etc.; after human change, dir is human's intended new dir. AI decides from there. If human held a direction, AI continues same. Good, feels responsive.
If human input rejected (reverse), dir unchanged, AI continues. Good.
Edge: human presses key at exactly tick boundary? Unlikely; keys fire between ticks typically. Fine.
Now AI also must avoid reversing into body — handled via bodyHas and the reverse-of-current exclusion logic (include reversed fallback). Good.
Also ensure AI doesn't pick direction causing immediate self-hit: candidates validated with bodyHas excluding tail — but head moving to a cell occupied by body except tail is fine (that's not self hit? moving INTO body cell when not the tail cell = self hit = death). bodyHas(excludeTail) returns true for body cells (not tail). So candidates that would land on body cell (non-tail) excluded → good, AI won't self-steer into body. But what about landing exactly on tail cell when not eating? bodyHas(excludeTail=true) excludes tail → AI could target tail cell (not eating → pops tail, head lands on former tail → death anyway). Hmm: if candidate nx,ny is the current tail cell, bodyHas excludes tail → considered safe → AI moves into tail cell. Next tick not eating → tail removed → head now safe (was tail cell freed). Actually that's LEGAL (moving onto tail then tail vacates). Many snake games allow it but typically game-over either way. To be safe & fair, exclude tail from bodyHas for candidate validation AND for the actual move we pop tail if not eating; if head lands on tail cell not eating → death. That creates unfair death. Let me adjust: in step(), when checking self-collision, should we allow head→tail-cell when not eating? Physical snake: occupying tail cell while moving onto it then moving away is legal IF tail moves. So allow it (not death). Change collision check to exclude tail (like AI), consistent. But then wall hit etc. still death. And the common rule "can't reverse onto itself" — landing on tail is part of that. I'll use bodyHas(true) for collision: head collides if hits ANY body cell EXCEPT the last tail (which will vacate when not eating).
Wait but if eating, tail doesn't vacate, so head landing on tail cell = death — correct. And non-eating: head lands on tail cell, tail moves → safe. Perfect, matches physical. Use bodyHas(excludeTail=true) everywhere for collision. AI same.
But the "snake can never reverse onto itself" — landing on tail counts. Covered.
One more: what about head colliding with itself in L-shape where head goes to neck (body[1])? bodyHas excludes tail only, neck is body[1] → collision detected → death. Correct.
Great.
Let me also ensure food placement excludes snake entirely (it does).
Also initial dir for snake: dir{1,0}, history {1,0}.
Alright also the demo "self-playing": AI steps every tick.
Also should the game possibly end on demo? AI rarely dies if decent. If dies, GAME OVER, human can restart. That's fine & interesting—shows mechanics. The screenshot-after-3s sees demo running in menu? No—menu shows static; after click demo runs. Screenshot at 3s with menu overlay visible + snake+food = meaningful. After click, gameplay visible. Good. Actually requirement: "Something meaningful must be visible on first paint: a screenshot is taken automatically about 3 seconds after load, without any interaction." Menu with title, controls, best score, snake in middle, food glowing — definitely meaningful.
Now finalize and write file. Let me also double-check no external resources: fonts system, colors hex, no URLs. Good.
Also `touch-action:none` helps mobile but we rely on keyboard + click. Fine.
Also prevent context menu / selection? Minor. Add user-select:none on body.
Let me also add a subtle animated border pulse to brand? CSS only; keep.
Write final HTML now, carefully and completely. I'll keep code tidy.
Let me reconsider cell sizing interplay with overlay overlay-radius 16 matches canvas. Good.
Also panel content typed into #panel. menuPanel includes "Best X" shown.
One more requirement: "Display the current score and the best score of the session." Top scoreboard does both. Also overPanel shows best. Good.
Let me write it all out.
Potential bug: `ctx.roundRect` exists in Chrome 99+. Task says latest Chrome — fine. I'll still feature-detect in helper (already did roundRectPath).
Bug check in step(): building nseg copies spread {...s,fx,fy,dir} — s has x,y,fx,fy,dir. Good. unshift nh. Then snake=nseg. Then check eat & pop? I had previously pop logic; now removed — eating doesn't pop tail. Non-eating: I pop tail: snake.pop(). Let me re-add: after nseg built: if(!bodyHas(food)) snake.pop(). Because tail should move. Let me include.
Wait but if I unshift and pop tail, length stays. Eat: unshift only, length+1. Correct.
So:
```
const nseg=[...]; nseg.unshift(nh);
if(!bodyHas(food.x,food.y)) snake.pop();
snake=nseg;
```
Hmm but pop removes last element which was at tail (old tail). Good. But note nseg includes all original segments + new head; the last element = old tail position; pop removes it.
Check eat: nseg length = original+1; no pop; grows.
Collision check uses bodyHas(excludeTail=true) comparing new head (nx,ny) against snake before mutation — I compare BEFORE unshift, against current snake (old positions). head not in snake yet. Compare against old snake. Good. But if head lands on old tail (non-eating), excluded → safe; old tail remains in nseg but gets popped → removed. Consistent: popping removes tail.
Edge: eat case old tail still in nseg; not popped; head now at tail's previous spot but that's fine (it grew onto tail cell? no—head moved to nx,ny which was food location, not tail). Actually if food was at tail cell, eating grows snake there → snake occupies tail cell still → fine (food not elsewhere). OK.
Write final.
Also add `user-select:none;` and `-webkit-user-select`.
Also the click listener: should start game on MENU. If state OVER (after game), click restarts — spec didn't say, but helpful. Keeps simple: any click starts. But during PLAY clicking does nothing (started already true, state PLAY, click does nothing). Fine.
Wait: click handler currently `if(state===MENU||OVER||PAUSE) startGame()` else does nothing. In PAUSE, click should NOT start (must press P to resume). Hmm—or click could dismiss pause? Keep: click only starts from MENU/OVER. During PAUSE ignore. Good, matches "only receive keyboard events after click" — mouse click also works though. Fine.
Let me also make sure that pressing keys while focus is elsewhere doesn't break; window listener covers.
Final code below. Also careful: `e.key` for 'p' lowercase vs 'P' handled.
Also Escape? no.
Let me also add tiny on-canvas "best" already in UI.
Time to output. I'll produce one fenced ```html block. Let me write the complete file.
Double-checking AI `bodyHas(nx,ny,true)` excludes tail — yes.
Also `aiStep` uses dir global; after drainHuman dir may be human's. Good.
Also guard AI when snake length<1? always ≥3. fine.
Also AI might choose direction == current dir (no move) — tryDir returns false (same as current) → no change, snake keeps moving. Distances: if all dirs collide, candidates empty; safeBest null; revBest maybe; chosen = revBest || dirs[last]. Could pick same dir as current (dirs[last]=current) → tryDir no-op → continue. ok falls through; snake just keeps moving into wall eventually → game over. Acceptable.
Initial dir{1,0}, snake horizontal middle. AI moves right... head reaches right wall → game over fairly quickly (first tick right wall). Hmm that kills the demo fast! cell sizes; at cell 28 px, walls far enough. Snake length 3 moving right from x=10 → after ~12 ticks hits x=21 wall. TICK_VAR ~130ms → ~1.5s. Still early but visible demo. To make demo nicer, initialize snake moving into open area: place at x=5 and start dir {0,1}? Or just place snake left-center moving up-left? Grid 22, place snake at bottom moving up. Head (10,19) dir {0,-1} → goes up to (10,0) after 19 ticks = ~2.5s. OK still quick.
Alternatively start snake in a corner facing open corridor. Place at (3,3) dir{1,0} — corridor to right x up to 21 then wall at tick ~18. Hmm.
Maybe make demo smarter: don't let it hit walls instantly — but AI avoids walls so it turns around eventually. Starting toward wall is the issue. Start snake facing an open face: e.g., head at (11,11) dir{-1,0} facing left into open space until x=0 at ~11 ticks ~1.4s.
Honestly the demo dying fast is OK for a "screenshot after 3s" test — by 3s it'd be game over anyway, showing game-over overlay with score 30 etc. That's meaningful content! The requirement only wants something visible at 3s; menu or game-over both qualify. But nicer if demo lives a bit longer initially. Let me start snake at lower area moving upward from (11,18) dir{0,-1}: reaches top (1,0) after 18 ticks ≈ 2.3s. Reasonable. Or (11,20)→top, 18 ticks. Let me do (11,19) dir up. Actually start a bit lower (11,20) → up to top row after 20 ticks ≈ 2.6s. Meh.
Better: start snake small, head at center, facing direction with empty corridor ahead for >15 ticks: head (11,11) dir{0,-1} from y=11→0 = 11 ticks ≈1.4s. Or place at (11,13) dir up → 13 ticks ≈1.7s.
Compromise: head (11, 16), dir{0,-1}. Travels 16 cells ≈ 2.1s before wall. Acceptable. Set in resetGame.
Also maybe give demo a bit more lenient: TICK faster early? Already ramps.
Fine. Also to extend demo life, AI is decent; deaths happen occasionally. Good enough.
Write it.
Actually, let me reduce risk: start snake moving horizontally left from (9,11): travels to x=0, 9 ticks ≈1.2s. Hmm short. Vertically from (11,17) up: 17 ticks ≈1.9s. Choose (11,17) up.
Let me also compute TICK_VAR: at start TICK_MAX=130; after score 0→ k=1 →130ms. score reaches 300 after 30 foods (~ very long). Mostly constant ~130 early. Good.
Also initial dir for snake should match? Not needed (menu no movement). resetGame sets dir{0,-1} matching? not required.
Also history initial: push initial dir so reversals respected. do that in resetGame and init.
Alright, writing final file now. I'll be careful with syntax.
For `roundRectPath` I wrote without beginPath context save—ensure beginPath called. My helper calls ctx.beginPath then roundRect. Good.
Also for circles helper inline.
Let me also define helper circle:
```
function dot(x,y,r){ctx.beginPath();ctx.arc(x,y,r,0,Math.PI*2);ctx.fill();}
```
Also stroke for grid uses beginPath per line—fine.
Ensure `ctx.fill()` after roundRectPath fills that path (we built path then fill). Yes.
FillStyle set before. Good.
Potential perf: fine.
Now write.
After writing mentally, produce output. Also ensure NO text outside fenced block. Include only the html code block.
Let me also verify the panel key styling inside .small uses span.key — defined CSS. Good.
One more: "Quick successive key presses must both be honored" — our drain at tick honors both (queued). Good.
Also "the snake can never reverse onto itself" enforced by tryDir. Good.
Final answer: single code block.