GLM 5.3
by Z.aiReasoningClean
Running live in a sandboxed frame. Click inside it before using the keyboard.
How it went
- Total time
- 12 min 26 s
- Cost
- $0.234
- Output tokens
- 64,857
- Spent thinking
- 54,823 tokens
- Speed
- 87 tok/s
- Console errors
- 0
- First token after
- 519 ms
- First visible text after
- 11 min 22 s
- Prompt tokens
- 491
- Code size
- 29.8 KB
- Finish reason
- stop
- Served by
- InferenceNet
- Model id
z-ai/glm-5.3- Reasoning sent
model default- Max output
- 943,718 tokens
- Generated
- Nov 12, 2026
What it was asked
The prompt
Build a polished Snake game in a single HTML file using vanilla JavaScript and a <canvas>. Requirements: - A grid of at least 20×20 cells, centered on the page. - Arrow keys and WASD control the snake. Quick successive key presses must both be honored, but the snake can never reverse onto itself. - Eating food adds 10 points and grows the snake by one cell; new food never spawns on the snake. - Game over when the snake hits a wall or itself, with an overlay showing the score; Space or Enter restarts, P pauses. - Visual polish: smooth movement, a snake with a distinct head and eyes facing its direction, and a small particle burst when eating. - Display the current score and the best score of the session.
System prompt, sent with every prompt
You are generating a self-contained web demo. It will be rendered automatically in a sandboxed iframe and compared side by side with the output of other AI models given the exact same task. Output rules: - Reply with exactly ONE fenced code block containing the complete file: ```html (or ```svg when the task asks for an SVG). No text before or after the code block. - Everything must be inline: CSS in <style>, JavaScript in <script>. No external files, images, fonts or network requests, except the CDN URLs explicitly allowed by the task. - Do not use localStorage, sessionStorage, cookies, IndexedDB, alert(), confirm(), prompt() or window.open(): they are blocked in the sandbox and will throw. - The page must look right in a 1280×800 viewport and adapt gracefully down to 375px wide. No horizontal scrollbar. - Something meaningful must be visible on first paint: a screenshot is taken automatically about 3 seconds after load, without any interaction. - Everything that makes the demo interesting must be visible within the first seconds: nothing hidden behind a high score, a later level or a long play session. - Games must start in a self-playing demo mode (a simple AI plays) behind a "Click or press a key to start" message, so the game can be judged in action without playing. - The iframe only receives keyboard events after the user clicks it: if the demo uses the keyboard, display a clear "Click or press a key to start" message. - The code must run with zero errors in the console of the latest Chrome.
Code run-1.html · 729 lines
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Snake</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
html, body { height: 100%; }
body {
height: 100vh; height: 100dvh; overflow: hidden;
background: #0a0f0c;
background-image: radial-gradient(1100px 640px at 50% 6%, rgba(74,222,128,.09), transparent 62%);
color: #e7f4ea;
font-family: ui-sans-serif, system-ui, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
display: flex; align-items: center; justify-content: center;
-webkit-user-select: none; user-select: none;
}
#app { display: flex; flex-direction: column; align-items: center; gap: 12px; padding: 12px; }
/* ---- header / HUD ---- */
#hud { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; }
.brand { display: flex; align-items: center; gap: 10px; }
h1 {
font-size: clamp(17px, 2.3vw, 23px); font-weight: 800;
letter-spacing: .38em; margin-right: -.38em;
background: linear-gradient(92deg, #c9f8d4, #4ade80 55%, #2f9e63);
-webkit-background-clip: text; background-clip: text; color: transparent;
}
.stats { display: flex; gap: 8px; }
.chip {
display: flex; flex-direction: column; align-items: center; gap: 1px; min-width: 72px;
padding: 5px 12px; border-radius: 11px;
background: rgba(255,255,255,.035); border: 1px solid rgba(255,255,255,.09);
transition: border-color .3s, box-shadow .3s;
}
.chip .label { font-size: 9px; letter-spacing: .18em; text-transform: uppercase; color: #8fa898; }
.chip .value {
font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
font-size: 16px; font-weight: 700; line-height: 1.2;
}
.chip.record { border-color: rgba(183,247,205,.55); box-shadow: 0 0 18px rgba(74,222,128,.28); }
.chip.record .value { color: #b7f7cd; }
.value.pop { animation: pop .32s ease; }
@keyframes pop { 0% { transform: scale(1); } 40% { transform: scale(1.28); } 100% { transform: scale(1); } }
#demoChip { display: none; flex-direction: row; align-items: center; gap: 7px; min-width: 0; }
body.demo #demoChip { display: flex; }
.dot {
width: 7px; height: 7px; border-radius: 50%; background: #fbbf24;
box-shadow: 0 0 10px rgba(251,191,36,.9); animation: blink 1.15s ease-in-out infinite;
}
@keyframes blink { 50% { opacity: .25; } }
/* ---- board ---- */
#stage { position: relative; }
body.demo #stage { cursor: pointer; }
canvas {
display: block; border-radius: 14px; background: #0f1a14; touch-action: none;
box-shadow: 0 0 0 1px rgba(134,239,172,.18), 0 30px 70px rgba(0,0,0,.55), 0 0 90px rgba(74,222,128,.06);
}
/* ---- overlays ---- */
.overlay {
position: absolute; inset: 0; display: flex; align-items: center; justify-content: center;
background: rgba(5,9,7,.28); border-radius: 14px;
opacity: 0; visibility: hidden; pointer-events: none;
transition: opacity .35s ease, visibility .35s;
}
.overlay.show { opacity: 1; visibility: visible; }
.card {
background: rgba(10,17,13,.72); border: 1px solid rgba(134,239,172,.2); border-radius: 18px;
padding: 20px 30px; text-align: center;
backdrop-filter: blur(9px); -webkit-backdrop-filter: blur(9px);
box-shadow: 0 24px 60px rgba(0,0,0,.5);
transform: translateY(8px) scale(.97); transition: transform .35s cubic-bezier(.2,.9,.25,1.2);
}
.overlay.show .card { transform: none; }
.ov-kicker { font-size: 10px; letter-spacing: .34em; text-transform: uppercase; color: #8fa898; margin-bottom: 6px; }
.ov-title {
font-size: clamp(26px, 4vw, 38px); font-weight: 800; letter-spacing: .3em; margin-right: -.3em;
background: linear-gradient(92deg, #c9f8d4, #4ade80 60%, #2f9e63);
-webkit-background-clip: text; background-clip: text; color: transparent;
}
.ov-title.sm { font-size: clamp(20px, 3vw, 26px); }
.ov-title.danger {
background: linear-gradient(92deg, #ffcbcb, #f87171 60%, #b91c1c);
-webkit-background-clip: text; background-clip: text; color: transparent;
}
.ov-sub { margin-top: 8px; font-size: 13px; color: #8fa898; }
.ov-sub b { color: #d9ffe6; font-family: ui-monospace, Menlo, Consolas, monospace; }
.cta {
margin-top: 16px; display: inline-flex; align-items: center; gap: 8px;
padding: 9px 18px; border-radius: 999px; font-size: 13px; font-weight: 600; color: #d9ffe6;
background: rgba(74,222,128,.12); border: 1px solid rgba(134,239,172,.4);
animation: ctaPulse 2s ease-in-out infinite;
}
@keyframes ctaPulse { 50% { box-shadow: 0 0 22px rgba(74,222,128,.35); border-color: rgba(183,247,205,.75); } }
.ov-keys { margin-top: 12px; font-size: 11px; color: #8fa898; }
.final { margin-top: 14px; }
.fl-label { display: block; font-size: 10px; letter-spacing: .26em; text-transform: uppercase; color: #8fa898; }
.fl-value { font-family: ui-monospace, Menlo, Consolas, monospace; font-size: 42px; font-weight: 800; color: #d9ffe6; line-height: 1.15; }
.newbest { margin-top: 4px; font-size: 11px; font-weight: 700; letter-spacing: .14em; color: #fbbf24; text-transform: uppercase; }
kbd {
display: inline-block; min-width: 20px; padding: 2px 6px; border-radius: 6px;
background: rgba(255,255,255,.07); border: 1px solid rgba(255,255,255,.14); border-bottom-width: 2px;
font-family: ui-monospace, Menlo, Consolas, monospace; font-size: 11px; color: #cfe8d6;
}
/* ---- footer ---- */
#foot { display: flex; align-items: center; justify-content: center; gap: 16px; flex-wrap: wrap; font-size: 11.5px; color: #8fa898; }
.hint { display: inline-flex; align-items: center; gap: 5px; }
@media (max-width: 520px) {
#hud { justify-content: center; }
.chip { min-width: 64px; }
#foot { gap: 10px; }
}
</style>
</head>
<body class="demo">
<div id="app">
<header id="hud">
<div class="brand">
<svg width="24" height="24" viewBox="0 0 24 24" aria-hidden="true">
<defs>
<linearGradient id="lg" x1="0" y1="1" x2="1" y2="0">
<stop offset="0" stop-color="#2f9e63"/><stop offset="1" stop-color="#a7f3c9"/>
</linearGradient>
</defs>
<path d="M4.5 17.5c0-3.2 3.4-3.6 6.2-3.9 3-.3 6.3-.9 6.3-4.2 0-2.6-2.2-3.9-4.6-3.9"
fill="none" stroke="url(#lg)" stroke-width="3.6" stroke-linecap="round"/>
<circle cx="17.2" cy="17.2" r="3.1" fill="url(#lg)"/>
<circle cx="18.3" cy="16.3" r=".7" fill="#0a0f0c"/>
</svg>
<h1>SNAKE</h1>
</div>
<div class="stats">
<div class="chip" id="demoChip"><span class="label">AI demo</span><span class="dot"></span></div>
<div class="chip" id="scoreChip"><span class="label">Score</span><span class="value" id="scoreVal">0</span></div>
<div class="chip" id="bestChip"><span class="label">Best</span><span class="value" id="bestVal">0</span></div>
</div>
</header>
<div id="stage">
<canvas id="game" aria-label="Snake game board"></canvas>
<div class="overlay show" id="ovStart">
<div class="card">
<div class="ov-kicker">Classic arcade</div>
<div class="ov-title">SNAKE</div>
<div class="ov-sub">The snake is playing itself — jump in anytime.</div>
<div class="cta">Click or press a key to start</div>
<div class="ov-keys"><kbd>↑</kbd><kbd>↓</kbd><kbd>←</kbd><kbd>→</kbd> or <kbd>WASD</kbd> to steer · <kbd>P</kbd> pause</div>
</div>
</div>
<div class="overlay" id="ovPause">
<div class="card">
<div class="ov-title sm">PAUSED</div>
<div class="ov-sub">Press <kbd>P</kbd> or click to resume</div>
</div>
</div>
<div class="overlay" id="ovOver">
<div class="card">
<div class="ov-title sm danger" id="ovOverTitle">GAME OVER</div>
<div class="final">
<span class="fl-label">Score</span>
<span class="fl-value" id="finalScore">0</span>
</div>
<div class="newbest" id="newBestTag" hidden>★ New session best ★</div>
<div class="ov-sub">Session best: <b id="finalBest">0</b></div>
<div class="cta">Press <kbd>Space</kbd> or <kbd>Enter</kbd> to restart</div>
</div>
</div>
</div>
<footer id="foot">
<span class="hint"><kbd>W</kbd><kbd>A</kbd><kbd>S</kbd><kbd>D</kbd> / <kbd>↑</kbd><kbd>↓</kbd><kbd>←</kbd><kbd>→</kbd> move</span>
<span class="hint"><kbd>P</kbd> pause</span>
<span class="hint"><kbd>Space</kbd> restart</span>
</footer>
</div>
<script>
(() => {
'use strict';
/* ================= constants ================= */
const COLS = 26, ROWS = 22; // 26×22 grid (both ≥ 20)
const BASE_TICK = 132, MIN_TICK = 68, TICK_STEP = 2.4;
const DIRS = { up:{x:0,y:-1}, down:{x:0,y:1}, left:{x:-1,y:0}, right:{x:1,y:0} };
const KEYMAP = {
ArrowUp:'up', KeyW:'up', ArrowDown:'down', KeyS:'down',
ArrowLeft:'left', KeyA:'left', ArrowRight:'right', KeyD:'right'
};
const IGNORE_KEY = /^(Shift|Control|Alt|Meta|CapsLock|NumLock|ScrollLock|Escape|Tab|ContextMenu|F\d)/;
const AI_DIRS = [DIRS.right, DIRS.left, DIRS.down, DIRS.up];
const PCOLORS = ['#ff6b5e', '#ffb35c', '#ffe066', '#8ce99a', '#ff8fa3'];
/* ================= dom ================= */
const canvas = document.getElementById('game');
const ctx = canvas.getContext('2d');
const hud = document.getElementById('hud');
const foot = document.getElementById('foot');
const scoreVal = document.getElementById('scoreVal');
const bestVal = document.getElementById('bestVal');
const scoreChip = document.getElementById('scoreChip');
const ovStart = document.getElementById('ovStart');
const ovPause = document.getElementById('ovPause');
const ovOver = document.getElementById('ovOver');
const ovOverTitle = document.getElementById('ovOverTitle');
const finalScore = document.getElementById('finalScore');
const finalBest = document.getElementById('finalBest');
const newBestTag = document.getElementById('newBestTag');
/* ================= state ================= */
let cell = 24, W = 0, H = 0, dpr = 1, bg = null;
let body = [], prevBody = [], dir = DIRS.right, queue = [];
let food = null, foodAt = 0;
let mode = 'demo', isDemoRun = true, overFrom = 'demo';
let score = 0, eaten = 0, best = 0, startBest = 0, tickMs = 118;
let lastTick = 0, pausedAt = 0, deathAt = 0, deathDir = { x: 1, y: 0 };
let particles = [], rings = [], pops = [];
let demoTimer = 0, overTimer = 0, lastFrame = 0, swipe = null;
/* ================= helpers ================= */
const easeOut = e => 1 - Math.pow(1 - e, 3);
const easeOutBack = x => { const c1 = 1.70158, c3 = c1 + 1; return 1 + c3 * Math.pow(x - 1, 3) + c1 * Math.pow(x - 1, 2); };
function popEl(el) { el.classList.remove('pop'); void el.offsetWidth; el.classList.add('pop'); }
/* ================= layout / sizing ================= */
function layout() {
dpr = Math.min(2, window.devicePixelRatio || 1);
const availW = Math.min(document.documentElement.clientWidth - 24, 1000);
for (let pass = 0; pass < 2; pass++) { // two passes: header may re-wrap after resize
const availH = window.innerHeight - hud.offsetHeight - foot.offsetHeight - 48;
cell = Math.max(10, Math.min(32, Math.floor(Math.min(availW / COLS, availH / ROWS))));
W = cell * COLS; H = cell * ROWS;
hud.style.width = W + 'px';
foot.style.width = W + 'px';
}
canvas.style.width = W + 'px';
canvas.style.height = H + 'px';
canvas.width = Math.round(W * dpr);
canvas.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
buildBG();
particles.length = 0; rings.length = 0; pops.length = 0;
}
function buildBG() { // pre-rendered checkerboard + vignette
bg = document.createElement('canvas');
bg.width = Math.round(W * dpr); bg.height = Math.round(H * dpr);
const b = bg.getContext('2d');
b.setTransform(dpr, 0, 0, dpr, 0, 0);
b.fillStyle = '#0f1a14';
b.fillRect(0, 0, W, H);
b.fillStyle = 'rgba(255,255,255,0.022)';
for (let y = 0; y < ROWS; y++)
for (let x = (y & 1); x < COLS; x += 2)
b.fillRect(x * cell, y * cell, cell, cell);
const g = b.createRadialGradient(W / 2, H / 2, Math.min(W, H) * 0.28, W / 2, H / 2, Math.max(W, H) * 0.72);
g.addColorStop(0, 'rgba(0,0,0,0)');
g.addColorStop(1, 'rgba(0,0,0,0.32)');
b.fillStyle = g;
b.fillRect(0, 0, W, H);
}
/* ================= game core ================= */
function resetGame(demo) {
isDemoRun = demo;
mode = demo ? 'demo' : 'playing';
clearTimeout(demoTimer); clearTimeout(overTimer);
demoTimer = 0; overTimer = 0;
queue.length = 0; particles.length = 0; rings.length = 0; pops.length = 0;
const cy = ROWS >> 1;
body = [];
for (let i = 0; i < 4; i++) body.push({ x: 12 - i, y: cy });
prevBody = body.map(c => ({ x: c.x, y: c.y }));
dir = DIRS.right;
score = 0; eaten = 0; startBest = best;
tickMs = demo ? 118 : BASE_TICK;
scoreVal.textContent = '0';
scoreChip.classList.remove('record');
document.body.classList.toggle('demo', demo);
ovStart.classList.toggle('show', demo);
ovPause.classList.remove('show');
ovOver.classList.remove('show');
placeFood();
lastTick = performance.now();
if (!demo) pops.push({ x: (body[0].x + 0.5) * cell, y: (body[0].y - 0.5) * cell, life: 0, max: 700, text: 'GO!' });
}
function startGame() { resetGame(false); }
function placeFood() { // never spawns on the snake
const occ = new Set(body.map(c => c.y * COLS + c.x));
const free = [];
for (let y = 0; y < ROWS; y++)
for (let x = 0; x < COLS; x++) {
const k = y * COLS + x;
if (!occ.has(k)) free.push({ x, y });
}
if (!free.length) { food = null; winGame(); return; }
food = free[(Math.random() * free.length) | 0];
foodAt = performance.now();
}
function step() {
if (mode === 'demo') { const d = aiDir(); if (d) dir = d; }
else if (queue.length) dir = queue.shift(); // one queued turn per tick
prevBody = body.map(c => ({ x: c.x, y: c.y }));
const nh = { x: body[0].x + dir.x, y: body[0].y + dir.y };
if (nh.x < 0 || nh.y < 0 || nh.x >= COLS || nh.y >= ROWS) { die(); return; }
const eating = food && nh.x === food.x && nh.y === food.y;
const lim = eating ? body.length : body.length - 1; // tail vacates unless growing
for (let i = 0; i < lim; i++) {
if (body[i].x === nh.x && body[i].y === nh.y) { die(); return; }
}
body.unshift(nh);
if (eating) onEat(); else body.pop();
}
function onEat() {
const fx = (food.x + 0.5) * cell, fy = (food.y + 0.5) * cell;
score += 10; eaten++;
scoreVal.textContent = score;
popEl(scoreVal);
if (!isDemoRun && score > best) { // live session-best
best = score;
bestVal.textContent = best;
popEl(bestVal);
scoreChip.classList.add('record');
}
tickMs = Math.max(MIN_TICK, (isDemoRun ? 118 : BASE_TICK) - eaten * TICK_STEP);
burst(fx, fy);
rings.push({ x: fx, y: fy, life: 0, max: 420 });
pops.push({ x: fx, y: fy - cell * 0.2, life: 0, max: 800, text: '+10' });
placeFood();
}
function die() {
mode = 'over';
overFrom = isDemoRun ? 'demo' : 'playing';
deathAt = performance.now();
deathDir = { x: dir.x, y: dir.y };
queue.length = 0;
scoreChip.classList.remove('record');
if (overFrom === 'demo') { // demo silently restarts
ovStart.classList.remove('show');
demoTimer = setTimeout(() => { demoTimer = 0; if (mode === 'over') resetGame(true); }, 1600);
} else {
const newRecord = score > startBest;
ovOverTitle.textContent = 'GAME OVER';
overTimer = setTimeout(() => {
overTimer = 0;
if (mode !== 'over' || overFrom !== 'playing') return;
finalScore.textContent = score;
finalBest.textContent = best;
newBestTag.hidden = !newRecord;
ovOver.classList.add('show');
}, 650);
}
}
function winGame() { // board completely filled
mode = 'over';
overFrom = isDemoRun ? 'demo' : 'playing';
deathAt = performance.now();
deathDir = { x: 0, y: 0 };
if (overFrom === 'demo') {
demoTimer = setTimeout(() => { demoTimer = 0; if (mode === 'over') resetGame(true); }, 1500);
} else {
finalScore.textContent = score;
finalBest.textContent = best;
newBestTag.hidden = !(score > startBest);
ovOverTitle.textContent = 'PERFECT!';
ovOver.classList.add('show');
}
}
function pauseGame() {
if (mode !== 'playing') return;
mode = 'paused';
pausedAt = performance.now();
ovPause.classList.add('show');
}
function resumeGame() {
if (mode !== 'paused') return;
lastTick += performance.now() - pausedAt; // keep interpolation continuous
mode = 'playing';
ovPause.classList.remove('show');
}
/* Input queue: quick successive presses are all honored, reversal impossible */
function enqueue(d) {
const ref = queue.length ? queue[queue.length - 1] : dir;
if (d.x === ref.x && d.y === ref.y) return;
if (d.x === -ref.x && d.y === -ref.y) return;
if (queue.length >= 3) return;
queue.push(d);
}
/* ================= demo AI (greedy + flood-fill safety) ================= */
function aiDir() {
const head = body[0];
let bestD = null, bestS = -Infinity;
for (const d of AI_DIRS) {
if (d.x === -dir.x && d.y === -dir.y) continue;
const nx = head.x + d.x, ny = head.y + d.y;
if (nx < 0 || ny < 0 || nx >= COLS || ny >= ROWS) continue;
const eating = food && nx === food.x && ny === food.y;
const blocked = new Set();
const lim = eating ? body.length : body.length - 1;
for (let i = 0; i < lim; i++) blocked.add(body[i].y * COLS + body[i].x);
if (blocked.has(ny * COLS + nx)) continue;
const area = flood(nx, ny, blocked);
const dist = food ? Math.abs(nx - food.x) + Math.abs(ny - food.y) : 0;
const s = (area >= body.length ? 1e6 + 500 - dist * 8 : area * 40 - dist * 8) + Math.random() * 3;
if (s > bestS) { bestS = s; bestD = d; }
}
return bestD;
}
function flood(sx, sy, blocked) {
const seen = new Uint8Array(COLS * ROWS);
const stack = [sy * COLS + sx];
seen[sy * COLS + sx] = 1;
let count = 0;
while (stack.length) {
const idx = stack.pop();
count++;
const x = idx % COLS, y = (idx / COLS) | 0;
if (x > 0 && !seen[idx - 1] && !blocked.has(idx - 1)) { seen[idx - 1] = 1; stack.push(idx - 1); }
if (x < COLS - 1 && !seen[idx + 1] && !blocked.has(idx + 1)) { seen[idx + 1] = 1; stack.push(idx + 1); }
if (y > 0 && !seen[idx - COLS] && !blocked.has(idx - COLS)) { seen[idx - COLS] = 1; stack.push(idx - COLS); }
if (y < ROWS - 1 && !seen[idx + COLS] && !blocked.has(idx + COLS)) { seen[idx + COLS] = 1; stack.push(idx + COLS); }
}
return count;
}
/* ================= effects ================= */
function burst(x, y) {
const s = cell / 28;
for (let i = 0; i < 16; i++) {
const a = Math.random() * Math.PI * 2;
const sp = (0.05 + Math.random() * 0.17) * s;
particles.push({
x, y,
vx: Math.cos(a) * sp, vy: Math.sin(a) * sp - 0.03 * s,
life: 0, max: 380 + Math.random() * 320,
size: cell * (0.05 + Math.random() * 0.075),
color: PCOLORS[(Math.random() * PCOLORS.length) | 0]
});
}
}
function updateFx(dt) {
const s = cell / 28;
for (let i = particles.length - 1; i >= 0; i--) {
const p = particles[i];
p.life += dt;
if (p.life >= p.max) { particles.splice(i, 1); continue; }
const f = Math.exp(-dt / 420);
p.vx *= f;
p.vy = p.vy * f + 0.00035 * s * dt;
p.x += p.vx * dt; p.y += p.vy * dt;
}
for (let i = rings.length - 1; i >= 0; i--) {
rings[i].life += dt;
if (rings[i].life >= rings[i].max) rings.splice(i, 1);
}
for (let i = pops.length - 1; i >= 0; i--) {
const q = pops[i];
q.life += dt;
q.y -= dt * 0.028 * s;
if (q.life >= q.max) pops.splice(i, 1);
}
}
/* ================= drawing ================= */
function drawFood(now) {
const grow = Math.min(1, (now - foodAt) / 280);
const gs = grow < 1 ? easeOutBack(grow) : 1;
const fx = (food.x + 0.5) * cell, fy = (food.y + 0.5) * cell;
const r = cell * 0.34 * gs * (1 + Math.sin(now / 300) * 0.05);
if (r <= 0) return;
ctx.save();
ctx.shadowColor = 'rgba(255,92,92,0.75)';
ctx.shadowBlur = cell * 0.8;
ctx.fillStyle = '#f4563f';
ctx.beginPath(); ctx.arc(fx, fy, r, 0, 6.2832); ctx.fill();
ctx.restore();
const g = ctx.createRadialGradient(fx - r * 0.4, fy - r * 0.45, r * 0.12, fx, fy, r * 1.05);
g.addColorStop(0, '#ffb9a8'); g.addColorStop(0.5, '#f4563f'); g.addColorStop(1, '#b52a2a');
ctx.fillStyle = g;
ctx.beginPath(); ctx.arc(fx, fy, r, 0, 6.2832); ctx.fill();
ctx.fillStyle = 'rgba(255,255,255,0.55)';
ctx.beginPath(); ctx.ellipse(fx - r * 0.35, fy - r * 0.42, r * 0.22, r * 0.13, -0.6, 0, 6.2832); ctx.fill();
ctx.strokeStyle = '#8a5a2b'; ctx.lineWidth = Math.max(1.3, cell * 0.055); ctx.lineCap = 'round';
ctx.beginPath(); ctx.moveTo(fx, fy - r * 0.8); ctx.quadraticCurveTo(fx + r * 0.1, fy - r * 1.15, fx + r * 0.32, fy - r * 1.22); ctx.stroke();
ctx.fillStyle = '#5ec98f';
ctx.beginPath(); ctx.ellipse(fx + r * 0.6, fy - r * 1.08, r * 0.34, r * 0.17, -0.5, 0, 6.2832); ctx.fill();
}
function drawSnake(now, t) {
const n = body.length;
const pts = new Array(n);
for (let i = 0; i < n; i++) { // interpolated centers → smooth motion
const from = i < prevBody.length ? prevBody[i] : prevBody[prevBody.length - 1];
const to = body[i];
pts[i] = { x: (from.x + (to.x - from.x) * t + 0.5) * cell, y: (from.y + (to.y - from.y) * t + 0.5) * cell };
}
if (mode === 'over') { // little "bonk" toward what killed us
const dd = now - deathAt;
if (dd < 330) {
const b = Math.sin(Math.min(dd / 330, 1) * Math.PI) * 0.3 * cell;
pts[0].x += deathDir.x * b; pts[0].y += deathDir.y * b;
}
}
const through = () => { ctx.beginPath(); ctx.moveTo(pts[0].x, pts[0].y); for (let i = 1; i < n; i++) ctx.lineTo(pts[i].x, pts[i].y); };
ctx.lineJoin = 'round'; ctx.lineCap = 'round';
ctx.save(); // soft green glow + dark rim
ctx.shadowColor = 'rgba(74,222,128,0.5)';
ctx.shadowBlur = cell * 0.7;
ctx.strokeStyle = '#0e3320';
ctx.lineWidth = cell * 0.84;
through(); ctx.stroke();
ctx.restore();
for (let i = n - 1; i > 0; i--) { // body gradient, tail → head
const k = 1 - i / (n - 1);
ctx.strokeStyle = 'hsl(' + (152 + 8 * k) + ', ' + (52 + 18 * k) + '%, ' + (26 + 30 * k) + '%)';
ctx.lineWidth = cell * (0.58 + 0.12 * k);
ctx.beginPath(); ctx.moveTo(pts[i].x, pts[i].y); ctx.lineTo(pts[i - 1].x, pts[i - 1].y); ctx.stroke();
}
ctx.strokeStyle = 'rgba(5,30,18,0.30)'; // subtle spine shading
ctx.lineWidth = cell * 0.14;
through(); ctx.stroke();
if (mode === 'over') { // red flash on death
const dd = now - deathAt;
if (dd < 320) {
ctx.strokeStyle = 'rgba(255,92,92,' + (0.5 * (1 - dd / 320)).toFixed(3) + ')';
ctx.lineWidth = cell * 0.84;
through(); ctx.stroke();
}
}
drawHead(pts, now);
}
function drawHead(pts, now) {
const hx = pts[0].x, hy = pts[0].y;
let dx = dir.x, dy = dir.y;
if (pts.length > 1) {
const vx = hx - pts[1].x, vy = hy - pts[1].y;
const m = Math.hypot(vx, vy);
if (m > 0.01) { dx = vx / m; dy = vy / m; }
}
const r = cell * 0.44;
ctx.fillStyle = 'hsl(140,72%,62%)';
ctx.strokeStyle = 'hsl(152,55%,24%)';
ctx.lineWidth = Math.max(1.5, cell * 0.09);
ctx.beginPath(); ctx.arc(hx, hy, r, 0, 6.2832); ctx.fill(); ctx.stroke();
ctx.fillStyle = 'rgba(255,255,255,0.16)';
ctx.beginPath(); ctx.arc(hx - r * 0.28, hy - r * 0.34, r * 0.42, 0, 6.2832); ctx.fill();
const px = -dy, py = dx;
if (mode !== 'over') { // flicking forked tongue
const tf = now % 2600;
if (tf < 240) {
const ext = Math.sin((tf / 240) * Math.PI);
const bx = hx + dx * r * 0.92, by = hy + dy * r * 0.92;
const mx = bx + dx * ext * cell * 0.42, my = by + dy * ext * cell * 0.42;
ctx.strokeStyle = '#ff5d7e';
ctx.lineWidth = Math.max(1.4, cell * 0.07); ctx.lineCap = 'round';
ctx.beginPath();
ctx.moveTo(bx, by); ctx.lineTo(mx, my);
ctx.lineTo(mx + dx * cell * 0.13 + px * cell * 0.1, my + dy * cell * 0.13 + py * cell * 0.1);
ctx.moveTo(mx, my);
ctx.lineTo(mx + dx * cell * 0.13 - px * cell * 0.1, my + dy * cell * 0.13 - py * cell * 0.1);
ctx.stroke();
}
}
const rot = Math.atan2(dy, dx); // eyes face the travel direction
const blink = (now % 3600) > 3480;
const eR = cell * 0.115;
for (let s = 1; s >= -1; s -= 2) {
const ex = hx + dx * cell * 0.15 + px * cell * 0.185 * s;
const ey = hy + dy * cell * 0.15 + py * cell * 0.185 * s;
ctx.fillStyle = '#f2fbee';
ctx.beginPath(); ctx.ellipse(ex, ey, eR * (blink ? 0.25 : 1), eR, rot, 0, 6.2832); ctx.fill();
if (!blink) {
const cx2 = ex + dx * cell * 0.045, cy2 = ey + dy * cell * 0.045;
ctx.fillStyle = '#14261c';
ctx.beginPath(); ctx.arc(cx2, cy2, cell * 0.055, 0, 6.2832); ctx.fill();
ctx.fillStyle = 'rgba(255,255,255,0.85)';
ctx.beginPath(); ctx.arc(cx2 - cell * 0.02, cy2 - cell * 0.02, cell * 0.018, 0, 6.2832); ctx.fill();
}
}
}
function drawFx() {
for (let i = 0; i < particles.length; i++) {
const p = particles[i];
const a = Math.max(0, 1 - p.life / p.max);
ctx.globalAlpha = a;
ctx.fillStyle = p.color;
ctx.beginPath(); ctx.arc(p.x, p.y, p.size * (0.4 + 0.6 * a), 0, 6.2832); ctx.fill();
}
for (let i = 0; i < rings.length; i++) {
const rg = rings[i], e = rg.life / rg.max;
ctx.globalAlpha = (1 - e) * 0.65;
ctx.strokeStyle = '#ffa46a';
ctx.lineWidth = Math.max(1, 3.2 * (1 - e));
ctx.beginPath(); ctx.arc(rg.x, rg.y, cell * (0.35 + easeOut(e) * 1.15), 0, 6.2832); ctx.stroke();
}
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
for (let i = 0; i < pops.length; i++) {
const q = pops[i], e = q.life / q.max;
ctx.globalAlpha = (1 - e) * (1 - e);
ctx.font = '700 ' + Math.max(12, Math.round(cell * 0.62)) + 'px Menlo, Consolas, monospace';
ctx.lineWidth = 4; ctx.lineJoin = 'round';
ctx.strokeStyle = 'rgba(6,20,13,0.85)';
ctx.strokeText(q.text, q.x, q.y);
ctx.fillStyle = '#d9ffe6';
ctx.fillText(q.text, q.x, q.y);
}
ctx.globalAlpha = 1;
}
function render(now) {
let t;
if (mode === 'paused') t = Math.min(1, (pausedAt - lastTick) / tickMs);
else if (mode === 'over') t = 1;
else t = Math.min(1, (now - lastTick) / tickMs);
ctx.clearRect(0, 0, W, H);
ctx.save();
if (mode === 'over') { // screen shake on death
const dd = now - deathAt;
if (dd < 340) {
const m = (1 - dd / 340) * 5;
ctx.translate((Math.random() * 2 - 1) * m, (Math.random() * 2 - 1) * m);
}
}
ctx.drawImage(bg, 0, 0, W, H);
if (food) drawFood(now);
drawSnake(now, t);
drawFx();
ctx.restore();
}
/* ================= main loop ================= */
function frame(now) {
requestAnimationFrame(frame);
if (!lastFrame) lastFrame = now;
const dt = Math.min(48, now - lastFrame);
lastFrame = now;
if (mode === 'playing' || mode === 'demo') {
if (now - lastTick > 1000) lastTick = now; // tab was hidden: don't fast-forward
let guard = 0;
while (now - lastTick >= tickMs && guard++ < 8) {
lastTick += tickMs;
step();
if (mode !== 'playing' && mode !== 'demo') break;
}
}
if (mode !== 'paused') updateFx(dt);
render(now);
}
/* ================= input ================= */
window.addEventListener('keydown', (e) => {
if (e.ctrlKey || e.metaKey || e.altKey) return;
const c = e.code;
if (mode === 'demo' || (mode === 'over' && overFrom === 'demo')) {
if (!IGNORE_KEY.test(c)) { e.preventDefault(); startGame(); }
return;
}
if (c === 'KeyP') {
e.preventDefault();
if (mode === 'playing') pauseGame(); else if (mode === 'paused') resumeGame();
return;
}
if (c === 'Space' || c === 'Enter' || c === 'NumpadEnter') {
e.preventDefault();
if (mode === 'over') startGame();
else if (mode === 'paused') resumeGame();
return;
}
const d = KEYMAP[c];
if (d) {
e.preventDefault();
if (mode === 'paused') resumeGame();
if (mode === 'playing') enqueue(DIRS[d]);
}
});
document.addEventListener('pointerdown', (e) => {
if (mode === 'over') { startGame(); return; }
if (mode === 'demo') { startGame(); return; }
if (mode === 'paused') { resumeGame(); return; }
if (mode === 'playing') swipe = { x: e.clientX, y: e.clientY };
});
window.addEventListener('pointermove', (e) => { // swipe steering (touch)
if (!swipe || mode !== 'playing') return;
const dx = e.clientX - swipe.x, dy = e.clientY - swipe.y;
if (Math.hypot(dx, dy) > 24) {
enqueue(DIRS[Math.abs(dx) > Math.abs(dy) ? (dx > 0 ? 'right' : 'left') : (dy > 0 ? 'down' : 'up')]);
swipe = { x: e.clientX, y: e.clientY };
}
});
window.addEventListener('pointerup', () => { swipe = null; });
window.addEventListener('pointercancel', () => { swipe = null; });
window.addEventListener('resize', layout);
window.addEventListener('blur', () => { if (mode === 'playing') pauseGame(); });
/* ================= boot ================= */
layout();
resetGame(true); // self-playing demo behind the start card
requestAnimationFrame(frame);
})();
</script>
</body>
</html>Reasoning trace (54,823 tokens)
Let me design a polished Snake game in a single HTML file.
Key requirements:
1. Grid of at least 20×20 cells, centered on page. I'll use 24×24 or maybe 28×22... Let me go with a nice grid like 24×24, or maybe a wider one like 26×20? "at least 20×20" — I'll do 24×24 square, or perhaps 26×22. Let me do 24×24 for a clean square centered.
Actually for a 1280×800 viewport, a square board of ~600px works well. Let me plan:
- Board: 24×24 cells, cell size dynamic based on viewport, e.g., min(560px, available width).
- Canvas with devicePixelRatio scaling for crispness.
2. Controls: Arrow keys + WASD. Quick successive key presses must both be honored — this means an input queue (buffer up to 2-3 moves). Snake can never reverse onto itself — when queued, validate against the previous queued direction or current direction.
3. Food: +10 points, grows by one cell, food never spawns on snake.
4. Game over: overlay showing score; Space or Enter restarts; P pauses.
5. Visual polish:
- Smooth movement: interpolate snake position between ticks using requestAnimationFrame. Snake segments render as smooth rounded path with interpolated head/tail positions.
- Distinct head with eyes facing direction.
- Particle burst when eating.
6. Display current score and best score of session (in-memory best, no localStorage — localStorage is blocked anyway).
7. Games must start in self-playing demo mode behind a "Click or press a key to start" message. So the AI plays automatically on load, with an overlay saying "Click or press a key to start". When the user clicks or presses a key, the game starts fresh with user control.
8. Keyboard events only after iframe click — so the message is required and I have it.
Let me think about the demo AI: A simple greedy pathfinder: move toward food, avoid walls and self. A decent heuristic:
- Compute candidate directions sorted by Manhattan distance to food.
- Prefer directions that don't collide; also add lookahead (avoid moves that lead into dead ends via flood fill). A simple flood-fill safety check makes the AI survive longer.
Let me implement:
- `chooseAIDirection()`: For each of the 3 possible directions (not reverse), simulate next head. If safe (in bounds, not on snake body excluding tail-if-not-eating), compute flood fill area from that position; pick direction maximizing (area size, -distance to food). Flood fill area should be >= snake length ideally.
Flood fill on 24×24 = 576 cells, doing it per tick is cheap.
Actually, for smooth movement, I'll structure it as:
- Logic tick at fixed interval (e.g., starts at 140ms, speeds up slightly as score increases, min ~70ms).
- Between ticks, `t = (now - lastTick) / tickInterval` in [0,1], and render interpolated positions.
Snake data: array of grid cells `{x, y}` from head to tail. For rendering smoothly, I compute for each segment a "from" and "to" position: essentially the previous snake positions and current. When the snake moves, each segment i moves from old position of segment i to new position. Actually simpler: keep `prevSnake` (positions before the tick) and `snake` (after tick). Interpolate each segment: `lerp(prevSnake[i], snake[i], t)`. When growing, the new tail segment's prev = its current position (so it stays). Since we add a new head and pop tail (or keep tail when growing), the segments align: after a move, snake[i] === prevSnake[i-1] for i>=1 typically. So prev of segment i is prevSnake[i] if it exists else snake[i].
Wait, careful: when the snake moves, new snake = [newHead, ...old positions except tail popped (unless growing)]. So newSnake[i] = oldSnake[i-1]. Interpolating segment i from oldSnake[i] to newSnake[i] means it slides one cell along the body — that gives a nice smooth slither where the whole body follows the path. The head interpolates from oldHead to newHead. Tail: if not growing, the last segment interpolates from oldTail to newTail position (= old snake[length-2])... hmm, actually with prevSnake being the old snake array (length N) and new snake (length N), segment i goes from prev[i] to cur[i]. cur[i] = prev[i-1], so each segment moves toward where the segment ahead was. That's exactly the follow behavior. Good.
When growing: new length N+1. cur = [newHead, ...prev all kept]. So cur[i] = prev[i-1] for i>=1, and cur[N] = prev[N-1] (the old tail stays). For interpolation, prevSnake for segment indices: segment 0..N-1 use prev[0..N-1]; segment N (new tail) prev = prev[N-1] = cur[N], so it doesn't move. So I'll build prev positions array with same length as cur, padding by repeating last.
Alternative approach that's often smoother: draw the snake as a path through interpolated points with rounded joints (lineJoin round, thick line). Then draw head circle on top with eyes. That looks quite polished. Let me do that:
Render:
1. Background: subtle dark theme, checkered cells (two very close shades) for grid texture.
2. Food: a glowing circle/apple with pulsing scale, maybe an apple with a leaf. I'll draw a nice apple: circle with radial gradient, small highlight, a little leaf/stem. Pulsing glow.
3. Snake: build interpolated point list (center of each cell), draw as a stroked path with round caps/joins, width ~ cellSize * 0.8, with gradient along body (head brighter green to tail darker). Then draw scales? Maybe subtle. Then head: slightly larger circle with eyes: two white ellipses with black pupils offset toward direction. Maybe tongue flicking occasionally.
4. Particles: on eat, spawn ~14 particles from food position with random velocities, gravity-ish fade, colored like food.
5. Score popups: floating "+10" text at food position.
Overlays:
- Start/demo overlay: "Click or press a key to start" with title, subtle.
- Pause overlay: "Paused — press P to resume".
- Game over overlay: "Game Over", score, best, "Space or Enter to restart".
HUD: Score and Best displayed above the board (or in a header bar), styled nicely with a font. Fonts: I can't load external fonts... "No external files, images, fonts" — only CDN URLs explicitly allowed by the task; task doesn't list any CDN, so I should avoid Google Fonts. Use system font stack — I can use a nice stack like `"Segoe UI", system-ui` or use monospace-ish styling. I could embed a font via base64 but that's huge. System fonts it is; I'll style with letter-spacing and weights to make it look designed. Maybe use `ui-monospace, "SF Mono", "Cascadia Code", Consolas, monospace` for the score digits for a retro-tech feel. That could look quite good with the dark theme.
Design theme: Dark charcoal background (#0d1117-ish or a deep green-black), board with slightly lighter panel, checkered cells in two dark greens, snake in vivid green (#4ade80 / #22c55e gradient), food as a red-ish apple (#f87171 / #ef4444) with glow. Accent color maybe lime. Title "SNAKE" with letter spacing.
Layout:
- Full viewport flex center, column: header with title + score/best chips, canvas board with rounded corners and border, footer hint with controls.
- Responsive: canvas size computed from `min(available width, available height minus header/footer)`, cell = floor(size/cols).
Let me set grid 24×24? Or 22×22? "at least 20×20" — I'll do 24 columns × 24 rows? Hmm, a square board at 600px. With header (~70px) + footer (~40px), total ~710px, fits in 800 height. Good. On mobile 375px wide, board ~355px, cell ~14.8px. Fine.
Actually maybe make it slightly wider than tall for typical screens: e.g., 28×20? At 1280×800 that'd be 700×500 which fits nicely. But square is classic. Let me do 26 wide × 20 tall? "at least 20×20 cells" — must be at least 20 in each dimension, so 26×20 satisfies "20×20 minimum". Hmm, "A grid of at least 20×20 cells" most likely means at least 20 by 20. 26×20 qualifies. But to be safe and classic, I'll do 24×24 square... Actually a wider board uses the 1280 viewport better and looks more "polished/composed" in a landscape screenshot. Let me do 28×20? That's 28 columns, 20 rows — satisfies at least 20×20? It's at least 20 in each dimension, yes. Hmm, but someone might parse "20×20 cells" strictly as needing both dims ≥ 20 — 28×20 has rows=20 which meets "at least". I'll go 28×20? Hmm... Let me reconsider: square boards center nicely and are unambiguous. I'll go with 24×24? At 1280×800 with header/footer, board 560-600px square. That leaves a lot of empty horizontal space but with a nice centered composition it's fine.
Alternatively 26×22 rectangle: 26 cols, 22 rows. Cell 24px → 624×528. Nice. Both dims ≥ 20 comfortably. Let me do that: COLS=26, ROWS=22. Aspect ratio ~1.18. Good for landscape screens, still fine on mobile (board scales to width, height smaller than width so no issue).
Hmm, actually let me reconsider board sizing to fit 800px height: header maybe 80, footer hint 36, gaps ~24 → board max height ~ 800-80-36-24-40(padding) ≈ 620. Width available 1280-40 padding = 1240. So board could be up to like 860 wide if height allows: height 620 → with aspect 26/22 = 1.18 → width 733. That's big and nice. I'll compute: boardSize = min(maxW, maxH * COLS/ROWS), cell = floor(boardW/COLS), then canvas = cell*COLS × cell*ROWS.
Actually simpler: use CSS to size the canvas element via aspect-ratio and flex, then in JS compute cell from clientWidth. Let me write a resize handler:
```
function resize(){
const wrap = ...;
const maxW = wrap.clientWidth;
const maxH = window.innerHeight - headerHeight - footerHeight - margins; // compute
...
}
```
Better: make the whole page a flex column with the board container flex:1 and min-height:0, then canvas sized to fit container maintaining aspect. Use ResizeObserver or window resize.
Let me structure:
```html
<div id="app">
<header> title, score chips </header>
<div id="stage"><canvas id="game"></canvas> (overlays absolutely positioned on stage)</div>
<footer> controls hint </footer>
</div>
```
CSS: html,body height 100%; #app: flex column, align center, height 100dvh, padding.
#stage: position relative, flex:0 0 auto... I need it to shrink. Let me: #stage { position:relative; height: min(calc(100dvh - Xpx), ...) } — messy. JS sizing approach: compute avail = window.innerHeight - header.offsetHeight - footer.offsetHeight - paddings; width avail = window.innerWidth - 32. cell = floor(min(availW/COLS, availH/ROWS)); clamp cell to [12, 34]. Set canvas.style.width = cell*COLS + 'px' etc., and canvas backing store = cell*dpr.
That's robust. Overlays positioned absolutely over the stage, matching canvas size — I'll put overlays inside #stage and set stage size to canvas size. Stage gets the border/rounded styling? Or canvas itself. I'll give canvas the border-radius and box-shadow; overlays inside stage with inset:0.
Now the game logic:
State machine: `mode` ∈ {demo, playing, paused, over}. Also 'starting' countdown? Maybe not needed; on click/key start, immediately begin with snake moving right from center. Small "3-2-1"? Could add a brief 1s "Go" but the requirement says nothing hidden; keep immediate but give the snake a short initial delay? I'll just start immediately — snake starts length 4 in the center moving right, at moderate speed. Fine.
Input queue: `dirQueue` array. On keydown: map key to direction vector; determine "reference direction" = last item in queue if any else current direction; if new dir is not same as reference and not reverse of reference, push to queue (limit length 3). This honors quick successive presses (e.g., up then left quickly → both applied on consecutive ticks).
On each tick: if queue non-empty, shift one and set direction. (Process one per tick to keep movement grid-locked.)
Wait — "Quick successive key presses must both be honored" — with a queue processed one per tick, pressing up then left within one tick applies up on next tick and left on the tick after. Both honored. Good. Also process the queue check so reversal relative to queued sequence is prevented.
Edge: at game start, direction is right; snake initial body horizontal. Prevent initial reverse.
Movement tick:
```
const head = {x: snake[0].x + dir.x, y: snake[0].y + dir.y};
if wall → gameOver
if collides with body (excluding the tail cell if not growing, since tail moves away) → gameOver
check food: if head == food → score += 10, grow (don't pop tail), spawn food, particles, maybe speed up
else pop tail
unshift head
```
Collision detail: if not eating, tail will move, so head can move into current tail cell. I'll check against snake.slice(0, len-1) when not eating, full body when eating (tail stays). Actually when eating, tail stays, so head must not equal any current segment including tail. Fine.
Food spawn: choose random from free cells. Build set of occupied cells; pick random free cell. With 26×22=572 cells, fine to loop or compute free list. I'll compute free list to guarantee never on snake.
Speed: base tick 140ms; decrease by ~2.5ms per food, min 70ms. Or speed based on score: `tick = max(70, 140 - score/10 * 2.5)`.
Smooth rendering: as described, keep `prevBody` aligned array.
Let me define:
- `body`: array of cells head→tail (current logical state after last tick).
- `prevBody`: positions of each segment before the tick, same length as body (for growth, pad with duplicate of last).
On tick:
```
prevBody = body.map(p => ({...p})) // snapshot BEFORE moving?
```
Hmm, careful: after moving, interpolation segment i goes from prevBody[i] to body[i]. If I snapshot body before the move as prevSnapshot, then after the move body_new = [head_new, ...oldBody (minus tail or not)]. Segment i in new body: for i>=1, body_new[i] = oldBody[i-1]; its prev should be oldBody[i]? Wait no — the physical segment that is now at index i... Let me think differently.
Think of segments as indexed from head: index 0 = head. After a move, the head moves to a new cell; every other segment moves into the cell previously occupied by the segment in front of it? No — actually in standard snake, the body cells are just the set of positions; after moving without growth, the new body = [newHead, oldBody[0..N-2]]. So newBody[i] = oldBody[i-1] for i>=1. The segment at index i "moves" from oldBody[i] to oldBody[i-1] — i.e., each segment moves forward along the path. Visually the whole snake translates along its path by one cell. Interpolating each index i from oldBody[i] (or clamped) to newBody[i] yields smooth following motion. For the tail when growing: newBody length N+1; newBody[N] = oldBody[N-1] (old tail cell). Its "from" should be oldBody[N-1] itself (it just spawned/stays). So: prev[i] = oldBody[i] if i < oldBody.length else oldBody[oldBody.length-1].
So:
```
function step(){
prevBody = body.map(c => ({x:c.x, y:c.y}));
// compute move...
body.unshift(newHead);
if (!ate) body.pop();
}
```
Then in render, for i in body: from = prevBody[i] ?? prevBody[prevBody.length-1]; pos = lerp(from, body[i], t).
At t between ticks: t = clamp((now - lastTickTime)/tickInterval, 0, 1). But when paused or game over, freeze t at... on pause, keep t; on game over, freeze at current t (maybe snap to 1? Slight visual: the head crashed into wall — freezing mid-motion then showing overlay is fine. Maybe on death animate a little shake/flash. I'll add a death animation: snake flashes red and a small shake of the canvas for 300ms before the overlay fades in. Overlay fades in with slight delay (400ms) via CSS transition.)
Rendering the snake body: Build interpolated center points: pts[i] = {x: (from.x + (to.x-from.x)*t + 0.5)*cell, y: ...}. Then draw path: ctx.beginPath, moveTo pts[0], lineTo through pts. If body length 1? Snake starts at length 4, fine. But note: consecutive interpolated points are always adjacent cells (since body is a connected path), so the polyline is smooth.
Stroke with lineWidth = cell*0.78, lineCap/lineJoin round, gradient: I can create a linear gradient between head and tail bounding... Simpler: draw the path multiple times? Or per-segment strokes with color interpolation — drawing each segment as its own stroke with round caps would create overlapping circles look... Actually a common trick: stroke the whole path once with a solid mid color, then overlay per-segment? Hmm.
Simplest good look:
1. Stroke whole path with dark green (border) width cell*0.82.
2. Stroke whole path with lighter green width cell*0.66.
3. Optionally draw subtle darker stripes/scales: small circles or short perpendicular ticks at each segment center with low alpha — might look noisy. Alternative: gradient along the path using canvas `createLinearGradient` from head point to tail point — not along path but decent since snake often roughly linear. Hmm, when snake folds, gradient direction wrong but colors still look okay (it's green→green variation).
I could do a per-segment approach: draw from tail to head, each segment as a thick round-capped line from pts[i+1] to pts[i] with color lerp(tailColor→headColor, i/N). With round caps and overlaps, later-drawn (closer to head) segments overlay earlier ones, giving a nice continuous gradient with each segment colored. Round caps ensure joints look smooth. Colors between adjacent segments differ slightly, so overlapping caps show a subtle ring — could actually look like scales! Slight banding may be visible but with subtle color shift it's fine. Let me try: color from tail (e.g., hsl(150, 60%, 32%)) to head (hsl(140, 75%, 55%)). The overlapping round caps might create visible scallops at the head end... Because each segment drawn over the previous one's cap. Since adjacent colors are close, it'll be subtle.
Alternative cleaner: use the path-stroke with a gradient trick by drawing N strokes, each shorter... overkill.
Actually a really clean approach: draw the full path with solid body color, then draw a second full path stroke with a semi-transparent lighter color but only for the first K segments near head (a second path through pts[0..K]) to brighten the head region — gradient-ish near head, fading via decreasing alpha strokes? Meh.
Let me go with per-segment strokes from tail to head with slight color lerp and round caps. It's simple and looks organic. Additionally add a subtle shadow: before drawing, stroke the path with black alpha at offset? Or use ctx.shadowBlur on the whole path once (shadow on first stroke). I'll draw an underlay stroke (dark, alpha 0.35, slightly larger) for depth.
Then head: draw circle at pts[0] radius cell*0.46, brighter color, with eyes. Eyes: based on current interpolated direction (vector from pts[1] to pts[0], normalized — use actual movement direction). Eye positions: perpendicular offsets. Each eye: white ellipse radius cell*0.13, pupil black radius cell*0.065 offset toward movement direction. Maybe add small tongue flicker: occasionally draw a red forked tongue ahead of head — cute polish. I'll add tongue flicking with sin-based timing: visible for ~150ms every ~2s. Actually keep it simple but include — it's nice polish. Tongue: line from head front, forking into two.
Eyes facing direction: compute dir = normalize(pts[0]-pts[1]) (or use current logical direction; during interpolation the head direction is exactly the movement direction, fine). Use `dir` for eye placement: eyes at head + dir*r*0.35 ± perp*r*0.42... Let me define perp = (-dy, dx). Eye centers: c = head + dir*(r*0.25) ± perp*(r*0.45). Pupil: c + dir*(r*0.12).
Food rendering: apple at cell center:
- glow: radial gradient or shadowBlur pulsing.
- body: circle radius cell*0.34 with radial gradient (light red → red), highlight ellipse white alpha.
- stem: short brown line; leaf: small green ellipse rotated.
- pulse: radius *= 1 + 0.06*sin(now/300).
Particles: array of {x,y,vx,vy,life,maxLife,color,size}. On eat spawn ~16 at food center, speeds random, friction, alpha fades, gravity slight. Render as circles with globalAlpha. Colors: food red/orange/green mix. Also spawn a floating score text "+10" that rises and fades.
Also maybe subtle grid dot texture: checkerboard: fill even cells with slightly lighter color. I'll do checker: for each cell if (x+y)%2 fill rgba(255,255,255,0.015). Plus outer board bg.
Also vignette on board? A radial gradient dark at edges — subtle depth. Could do once per frame, cheap.
Death effect: flash the snake red: store deathTime; when rendering snake during 'over' mode, mix color toward red based on time since death (flash alternate). And canvas shake: translate random small offsets decaying over 350ms. Then overlay appears.
Overlay design: absolutely positioned div with backdrop blur (backdrop-filter: blur(4px)) and dark translucent, rounded, containing text. Transitions: opacity+scale.
Overlay contents by mode:
- demo: title "SNAKE", subtitle "Watch the demo — the snake plays itself", big button-ish text "Click or press a key to start", small controls hint.
- playing: hidden.
- paused: "PAUSED" + "Press P to resume".
- over: "GAME OVER" + score big + best + "Press Space or Enter to restart".
Hmm, for demo mode: the AI plays behind the overlay. The overlay should let the game be visible — make it a bottom banner? The requirement: "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." So overlay must not obscure the game too much. I'll make the demo overlay a translucent panel at the top or center-bottom with pointer-events: none? But clicking must start the game — attach click handler on window/canvas. Make overlay pointer-events:none and handle clicks globally. Good.
Design: center overlay with strong blur would hide the game. Better: a compact centered card with modest size, semi-transparent — game still visible around it. Or place it at bottom: pill-shaped banner "Click or press any key to play". Let me do a centered card but narrow: title "SNAKE", then a pill "Click or press a key to start". The board is large (like 700×530), a 300px-wide card covers the middle... The AI snake may pass behind it — with backdrop blur it looks stylish. To keep visibility, I'll use a translucent dark card (rgba(10,15,12,0.55)) with blur(6px) — you can still see the snake move behind it dimly. Alternatively position the card near the bottom-center so the snake (which starts center) is mostly visible. The AI snake roams though.
Compromise: card centered but small height (~180px), semi-transparent. Board 530px tall → 350px visible around. Fine.
Also show HUD scores during demo (demo score counts but best score — should the demo AI's score count toward "best of session"? Requirement says "best score of the session". Demo is a display; I'd rather track best only for real games. Hmm, but "best score of the session" — if the AI plays and you never play, best stays 0. I'll track best from any completed run? If AI gets 200 and player's real game gets 50, showing best 200 is odd but honest. I think tracking best only from player games is more meaningful. But then during demo, best shows 0 — fine, or show "—". Actually simpler and defensible: update best whenever a playing-mode run ends OR during play whenever score exceeds best. I'll only update best in 'playing' mode. During demo, show score labeled "DEMO" maybe? That could be a nice touch: score chip shows the demo's score with a "demo" tag? Simpler: HUD shows Score and Best; during demo mode the Score chip shows the AI score but best not updated. Hmm, might confuse judges comparing? It's fine — I'll add a tiny "DEMO" badge on the score chip in demo mode. Actually let me keep the HUD clean: Score: current run (demo or real), Best: best of real runs this session. I'll add small "auto" indicator in demo.
Let me also handle: after user starts (click or key), reset game with mode='playing'. If user presses key during demo — that key shouldn't also apply as a direction? E.g., pressing ArrowUp to start would also queue Up — snake initial dir right, Up is fine actually. But cleaner: the starting keypress starts the game and does NOT queue direction, except... eh, just consume it. If it's an arrow/WASD, start the game and ignore it as input? Some might expect pressing up to start moving up. I'll ignore for simplicity and predictability — actually, honoring it feels responsive: if starting key is a direction key, apply it if valid (not reverse). Hmm, initial snake horizontal pointing right; pressing Left would reverse — blocked. I'll just consume the first key to start. Keep simple.
Restart with Space/Enter only in 'over' mode. In demo mode, any key/click starts. In playing mode, P pauses. In paused, P resumes (also maybe Space? No — spec says P pauses; resume also P). Also clicking while paused could resume — I'll allow click to toggle pause? Might cause accidental resume. I'll make click resume too (and show hint). Also Escape? Not needed.
Also: prevent default scrolling for arrows/space (important in iframe). e.preventDefault() for handled keys.
Now the AI (demo mode):
```
function aiChooseDir(){
const head = body[0];
const dirs = [{x:1,y:0},{x:-1,y:0},{x:0,y:1},{x:0,y:-1}];
// filter reverse
const cur = dir;
const options = dirs.filter(d => !(d.x === -cur.x && d.y === -cur.y));
let best = null, bestScore = -Infinity;
for (const d of options){
const nh = {x: head.x + d.x, y: head.y + d.y};
if (nh.x < 0 || nh.y < 0 || nh.x >= COLS || nh.y >= ROWS) continue;
const eating = (nh.x === food.x && nh.y === food.y);
// collision: body cells excluding tail if not eating
const bodyLen = eating ? body.length : body.length - 1;
let hit = false;
for (let i = 0; i < bodyLen; i++){ if (body[i].x===nh.x && body[i].y===nh.y){ hit=true; break; } }
if (hit) continue;
// flood fill area
const area = floodArea(nh, eating ? body : body.slice(0, body.length-1));
const dist = Math.abs(nh.x - food.x) + Math.abs(nh.y - food.y);
// prefer large area, then small dist
let s = 0;
if (area >= body.length + 2) s += 1000;
else s += area * 4; // heavily weight area
s -= dist * 3;
// small random tie-break
s += Math.random() * 0.1;
if (s > bestScore){ bestScore = s; best = d; }
}
if (best) return best;
return cur; // doomed, keep going (or any safe?) — if no safe option, keep cur
}
```
floodArea: BFS from nh over free cells where blocked = body cells (including tail? For simplicity treat whole body as blocked, and treat nh's neighbors... Actually flood from the next head position; count reachable free cells. If area >= snake length, safe. Implementation: build a Set of "x,y" for body (maybe excluding tail since it moves — keep it simple: exclude tail when not eating). BFS with array.
Also for a bit of liveliness in demo, AI could take slightly longer paths — the dist weighting handles it: it moves roughly toward food while avoiding traps. This is a solid demo AI that survives a while. When the snake gets long it eventually may die — then demo restarts itself after a short pause ("over" state in demo → auto restart after ~1.6s, with overlay staying as the start prompt). Good: game over overlay only in playing mode; in demo mode, show a brief "AI crashed — restarting" mini toast? Just auto-restart silently after 1.2s. The start overlay persists the whole demo.
Now let me think about the tick/loop:
```
let lastTick = performance.now();
function frame(now){
requestAnimationFrame(frame);
// update ticks
if (mode === 'playing' || mode === 'demo'){
let elapsed = now - lastTick;
while (elapsed >= tickInterval){
lastTick += tickInterval;
elapsed -= tickInterval;
step();
if (mode==='over') break; // death stops stepping
}
}
render(now);
}
```
Pause handling: when paused, don't advance lastTick; on resume, lastTick = now. Actually with the `while` approach based on lastTick accumulating, pausing just skips the update; on resume set lastTick = now to avoid burst. Also when mode==='over', stop ticking. For t interpolation in render: if playing/demo: t = clamp((now-lastTick)/tickInterval,0,1); if over: t = deathT (frozen at death, maybe animate a small overshoot). If paused: keep a stored pausedT? Simpler: on pause, snap: set lastTick such that t stays? When paused, we freeze rendering interpolation by using stored tFrozen. Let me just compute t normally but when paused, don't advance (lastTick untouched) and t = (pausedAt - lastTick)/tickInterval constant. Since now keeps increasing, t would grow — so store `pauseStart` and on render during pause use t = (pauseStart - lastTick)/tickInterval. On resume: lastTick += (now - pauseStart). Clean.
Death: when step() detects collision, mode='over', deathAt=now, deathT = 1 (snap head to final cell? Head crashed into wall — head shouldn't visually enter the wall cell. If we set t=1, head enters the wall visually. Better freeze at like t=0.45 so it "bumps". Hmm: prev head → new head is the wall cell; freezing at ~0.4 shows head nearly touching wall. For self-collision similar. I'll set deathT=0.45 and add a small recoil? Keep freeze at 0.45 with red flash + shake. But then the body segments also freeze mid-move — consistent, fine.
Actually there's a subtlety: at death, body wasn't updated (we detect collision before committing the move). So body stays at pre-move state; prevBody from previous tick. t frozen mid interpolation between prevBody and body. Hmm wait — during normal play, at any moment we're interpolating prevBody→body where body is the last committed state. When we detect a collision in step(), we don't commit the move; body remains; the interpolation continues to t=1 then freezes... With `while` loop: after step() sets mode='over', break. t computed as (now-lastTick)/tickInterval could exceed 1 → clamp to 1. So snake completes its last movement then freezes. But the head's last committed cell is one before the wall — so head stops adjacent to the wall, not visually crashing. That looks like it stopped just before collision — acceptable? A death flash + shake at that position reads as crash. Slightly better: on death, commit a "ghost" move so the head visually presses into the wall by ~40%: I can special-case render: store `deathDir` and render head extended toward the wall by t*0.4. Simpler alternative: on collision, still update prevBody/body but clamp? No.
Nice trick: on death, set prevBody/body unchanged but set a flag `bump = {dir}`; in render, if mode==='over', draw head position at pts[0] + dir*0.35*cell*(some easing) with a small overshoot animation: e.g., bump progresses 0→0.4→0 over 300ms (elastic-ish). And shake. That reads great: snake head bonks into the wall and bounces back. Let me implement:
```
const dt = now - deathAt;
const bumpT = Math.sin(Math.min(dt/300,1) * Math.PI) * 0.35; // 0→0.35→0
headRenderPos += deathDir * bumpT * cell
```
And flash: snake color lerps to red for first 200ms: mix based on (1 - min(dt/250,1)) maybe with a flash: alpha pulse red overlay. Simple: after drawing snake normally, if dt<300, redraw head circle with red fill alpha = 0.6*(1-dt/300). Fine.
Also screen shake: in render, ctx.translate(rand*mag, rand*mag) where mag = 6*(1 - min(dt/350,1)), applied to whole board drawing. Use deterministic-ish random fine.
Game over overlay: show after 500ms delay: CSS class toggled with transition-delay, or JS setTimeout. Use JS: on death, setTimeout(()=>showOverlay('over'), 550). But if user restarts fast? They can't before overlay (space handled in keydown when mode==='over' regardless of overlay). Guard: store timeout id, clear on restart.
Now scoring HUD: DOM elements updated on change: scoreEl.textContent, bestEl.textContent. Add a small pop animation on score change (CSS class re-trigger).
Also add "speed" indicator? Not needed. Maybe show length? Score chip and Best chip suffice; maybe also a "LEN" chip — extra flavor: Length chip. Sure, three chips: SCORE, BEST, LENGTH? Keep SCORE & BEST per requirements, plus length adds nice info. I'll include length too — it's cheap and informative.
Footer: key hints with kbd styling: "← ↑ ↓ → / WASD move · P pause · Space restart". Use kbd elements.
Colors palette (dark, polished):
- Page bg: #0b0f0d? Let me craft: deep green-black `#0a0f0c`. Add subtle radial glow behind board: `radial-gradient(ellipse at 50% 20%, rgba(74,222,128,0.08), transparent 60%)`.
- Board bg: `#0f1713`, cells checker overlay `rgba(255,255,255,0.02)`, board border: `1px solid rgba(74,222,128,0.25)`? plus inner shadow and outer glow: box-shadow: 0 0 0 1px rgba(74,222,128,.14), 0 20px 60px rgba(0,0,0,.5), 0 0 80px rgba(74,222,128,.06).
- Snake: head `#a3e635`-ish? Classic snake green: head `#7ef29a`? Let me use HSL: head hsl(140, 70%, 62%), tail hsl(155, 55%, 30%). Stroke outline darker: rgba(0,0,0,0.35) underlay.
- Food apple: red `#ff5d5d`/`#e04040` with glow `rgba(255,80,80,0.35)` pulse.
- Text: `#e8f5ec`, muted `#7d9385`.
- Accent: `#4ade80`.
Fonts: system stack; title in a spaced uppercase; numbers in ui-monospace.
Let me also add a subtle animated background? Maybe fine without. Add slight noise? skip.
Now sizes: COLS=26, ROWS=22? Wait I want ≥20×20. 26×22 both ≥20? Rows=22 ≥20 yes. OK. Hmm, let me reconsider: maybe 24×20... rows=20 exactly ≥20 fine. I'll go 26×22 for margin.
Initial snake: length 4, horizontal, at center row (ROWS/2), cols around center, moving right: body = [{x:13,y:11},{x:12,y:11},{x:11,y:11},{x:10,y:11}] with COLS=26,ROWS=22 → center (13,11). Head at 13.
Food spawn initial: random free cell not too close to head? Just random free.
Tick interval: start 130ms; per food: interval = Math.max(70, 130 - score * 0.35)? score increments by 10 → after 10 foods (100 pts) → 96.5ms; after 20 foods 60 → clamped 70. Reasonable ramp. For demo maybe fixed 110ms.
Particles at eat: also brief board flash? Add "eat flash" ring: expanding circle outline at food position fading — nice. I'll add ring effect: {x,y,r0,r1,life}.
Score popup: {x,y,text,life} rendered on canvas, rising, fading, bold font.
Eyes: also make them blink occasionally? Extra cuteness: blink every ~3.5s for 120ms (draw eyes as lines). Cheap: if ((now % 3500) < 130) draw squinted. I'll add.
Tongue: every ~2.8s flick 260ms: draw forked tongue from head front. Compute using dir. Draw two short lines red/pink (#ff6b81). With animation: extend out and back: len = sin(progress*PI) * cell*0.5.
Now checkered pattern: draw each frame is fine (26*22=572 rects — fine). Could pre-render to offscreen canvas on resize for perf. Let me pre-render the board background (bg fill + checker + vignette) into an offscreen canvas at resize, then drawImage each frame. Cleaner and fast. Also draw subtle border inside? The CSS border-radius clips canvas corners; also draw rounded rect? CSS handles radius with overflow hidden on canvas — border-radius on canvas element works with overflow of the element itself (canvas content is clipped by border-radius? Yes, border-radius clips the canvas element's rendering). Good.
DPR: set canvas.width = cssW * dpr; ctx.setTransform(dpr,0,0,dpr,0,0) each resize; draw in CSS pixels.
Now overlays: single overlay div reused with innerHTML set per mode? Cleaner: separate divs: #startOverlay, #pauseOverlay, #overOverlay, toggled with .show class. Overlays inside #stage (position absolute inset 0, display flex center). pointer-events: none so clicks pass to window handler. I'll attach click on document.
But careful: click to start also fires on any click; also clicking during play does nothing (or maybe nothing). Fine.
Also keydown listener on window — in sandboxed iframe, keyboard after click; we display the message. Good.
Let me also make sure "no errors in console". Careful with code.
Edge: requestAnimationFrame loop always running; render even in demo. Initial paint: game visible immediately with overlay — good for screenshot at 3s (snake moving in demo).
Let me now also handle the "honor quick successive presses" more concretely: queue max 3. Also when a direction is pressed twice (same dir), ignore duplicates. When pressing reverse of current effective dir, ignore. Effective dir = queue.length ? last queued : dir.
Also, note: I set `dir` at tick start from queue: `if (queue.length) dir = queue.shift();` then move. Validation done at enqueue time relative to the queue tail, so consecutive queued items are consistent.
Hmm, one more: if user queues Up then Left quickly, both honored over two ticks — good.
Pause: 'p' or 'P'. Restart: Space/Enter when over. Start: any key when demo (except maybe modifier keys — ignore pure modifier keydown (e.key === 'Shift' etc.)? A key press of Shift shouldn't start? "Click or press a key to start" — any key is fine, but ignore e.g. 'Meta'? I'll accept most keys; ignore if e.key in ['Shift','Control','Alt','Meta'] maybe. Fine.)
Also handle visibility change: if tab hidden, pause? The while loop could accumulate many ticks on tab return — with lastTick accumulation it would fast-forward many steps (bad). Use clamp: if elapsed > 1000, reset lastTick = now (skip). I'll add: `if (now - lastTick > 1000) lastTick = now;` before the while. Good.
Now, the demo AI uses queue too? The AI can just set dir directly each tick before step: in step(), if mode==='demo', dir = aiChooseDir() (validated no-reverse inside aiChoose). Simpler: in the tick function:
```
function step(){
if (mode === 'demo'){
const d = aiChooseDir();
if (d) dir = d;
} else if (queue.length){
dir = queue.shift();
}
...
}
```
Also demo auto-restart: when mode==='over' && wasDemo, setTimeout(()=> { if still over && still demo-ish ... resetGame(true) }, 1400). Manage flag `demoMode` separate from mode? Let me use states: 'demo', 'playing', 'paused', 'over', plus `overFrom` = 'demo'|'playing'. Pause only from playing.
Let me define functions:
- resetGame(demo): sets body, dir right, score 0 (display), spawn food, tick=base, prevBody=copy, lastTick=now, clear particles, mode = demo?'demo':'playing'.
- gameOver(): mode='over'; overFrom = previous; best update if from playing; schedule overlay or demo restart.
HUD score shows during demo the AI's score — okay, plus a "DEMO" chip visible. I'll add a small pill in header: "AI DEMO" shown only in demo mode (hidden otherwise). Nice touch communicating state.
Now, best score update: on gameOver from playing: best = Math.max(best, score). Also update HUD. Could update live during play when surpassing best? Classic: update best at game over; but showing "Best" update live is satisfying. Requirement: "Display the current score and the best score of the session." Either fine. I'll update best live when score > best during playing (and mark it). Hmm — but then on death best already equals score. That's typical (like Google snake records best after game... it updates at end). I'll update at game over only, but highlight the best chip when current exceeds best ("new best!" glow). Let me do: during play, if score > best, add class 'record' to score chip (pulse). On game over, best = score. Simple and juicy.
Now write render():
```
function render(now){
// t
let t;
if (mode==='playing'||mode==='demo') t = clamp((now-lastTick)/tickMs,0,1);
else if (mode==='paused') t = clamp((pausedAt - lastTick)/tickMs,0,1);
else t = 1; // over: completed last move
ctx.clearRect / draw bg image
ctx.save();
// shake
if (mode==='over' && overFrom... ) apply shake translate
drawFood(now) (if not eaten... food always present)
drawSnake(t, now)
drawParticles(now) + update them with dt
drawRings, popups
ctx.restore();
}
```
Particles update should use dt from frame times: keep lastFrame time; dt = now - lastFrame; update in ms.
drawSnake: compute pts as above. Draw glow under snake? A soft shadow via ctx.shadowBlur when stroking the path once: shadowColor rgba(74,222,128,0.35), blur 18 — stroking per segment would reapply shadows 20+ times (expensive-ish but okay). Better: first pass: single path stroke (all points) with shadowBlur for glow, using dark color & wide width. Then turn off shadow, then per-segment color strokes. That gives a nice glow + gradient body.
Per-segment stroke from tail to head:
```
for (let i = pts.length - 1; i > 0; i--){
const k = 1 - i/(pts.length-1); // 0 tail → 1 head
ctx.strokeStyle = lerpColor(tail, head, k)
ctx.lineWidth = cell*0.72 * (0.9 + 0.1*k) // slightly thicker near head
round caps
ctx.beginPath(); moveTo(pts[i]); lineTo(pts[i-1]); stroke();
}
```
Wait, drawing tail-first then head segments later means head-side segments overlay. Adjacent color diffs small → looks like smooth gradient with subtle scalloped edges from round caps... Each stroke is a capsule covering the segment; overlapping capsule ends near joints — since colors similar, joints look smooth, like scales catching light. I think it looks good. To reduce banding, use many small color steps — inherently fine.
Alternatively, gradient stroke via createLinearGradient(pts[0], pts[last]) applied to a single path stroke: band-free but gradient direction is straight-line head→tail, so when snake U-turns, middle segments get mid colors — acceptable actually. But per-segment gives correct along-path coloring. Keep per-segment.
Belly stripe? skip.
Head drawing:
```
const hx = pts[0].x, hy = pts[0].y;
const dirv = normalize(pts[0] - pts[1]) — if length 0 (start), use dir.
// circle
ctx.fillStyle = headColor; arc(hx,hy, cell*0.46)
// slight darker rim: stroke
// eyes...
// tongue...
```
Also add a subtle head gradient: radial from upper-left lighter. Keep simple: solid + small highlight arc.
Also draw a slight shadow under head? skip.
Food eaten handling: when eaten, particles spawn at food's cell center — but food was at head's new cell; spawn there. Also popup "+10".
Speed increase per eat: tickMs = Math.max(70, baseTick - eaten*2.2) with baseTick 130 (playing). Demo: keep 115 constant? Use same ramp in demo for excitement; on reset restore.
Ring effect: expanding stroke circle at eat: life 400ms, r from cell*0.4 to cell*1.2, alpha fade.
Now the checker background pre-render:
```
function buildBG(){
bg = document.createElement('canvas'); size = W,H (device px * dpr)...
// simpler: draw in CSS px with dpr transform
bctx.fillStyle = '#101a15'... fill all
checker: rgba(255,255,255,0.022) on (x+y)%2==0
vignette: radial gradient transparent center → rgba(0,0,0,0.28) edges
maybe faint inner border line: stroke rect inset 0.5 with rgba(126,242,155,0.06)
}
```
Then drawImage(bg, 0,0, cssW, cssH).
Board CSS: border-radius: 14px; box-shadow etc. Canvas display block.
Page composition:
- Header row: left: title "SNAKE" with a small snake glyph? Maybe a little inline SVG logo (allowed — inline, no external). A small snake icon: could draw with canvas... or inline SVG in header. I'll do a tiny SVG snake curve. Or skip; use a colored dot. I'll do: title with gradient text (background-clip). Keep classy.
- Right side: chips: SCORE (value), BEST (value), plus demo pill.
Layout: header width matches board width (max 900px?). Actually center everything: `#app { max-width: fit }`. I'll set header width equal to stage width dynamically? CSS: put header inside a wrapper whose width is set to canvas width via JS (or just use `width: min(96vw, ...)`) — simpler: wrap header+stage+footer in a column with `align-items:center`, header `width:100%` but constrained to stage width... I'll set `#hud { width: stageW + 'px' }` from JS on resize. Easy: after computing cell and board css size, set stage style and hud/footer style width.
Mobile: 375px → cell = floor((375-24)/26)=13.5→13 → board 338×286. Header chips wrap? Three chips small; font sizes ok. Title + chips might overflow 338px: make header flex-wrap with gap; title smaller on mobile via clamp().
Footer hint may overflow; allow wrap, small text, kbd elements. Fine.
Vertical fit on mobile: board height 286 + header ~110 + footer ~60 → fits.
Also `overflow:hidden` on body? Requirement: no horizontal scrollbar — ensure margins small; use `width:100%` with box-sizing. Add `overflow-x:hidden` safe? It's acceptable but better to size properly. I'll still add `overflow:hidden` on body vertically too? Height might exceed with big footers... Use 100dvh flex with min-height:0 and let JS fit board into remaining height. Body overflow hidden to avoid scrollbars — but if content taller than viewport on tiny screens, it'd clip. With JS sizing board to fit, header+footer are small. I'll set body overflow:hidden and ensure fit. Risky edge: very short landscape phones. Accept.
JS sizing detail:
```
function layout(){
const dpr = Math.min(window.devicePixelRatio||1, 2);
const availW = Math.min(window.innerWidth - 24, 980);
const chromeH = hud.offsetHeight + footer.offsetHeight + 34;
const availH = window.innerHeight - chromeH - 16;
let cell = Math.floor(Math.min(availW/COLS, availH/ROWS));
cell = Math.max(11, Math.min(cell, 34));
W = cell*COLS; H = cell*ROWS;
canvas.style.width = W+'px'; canvas.style.height = H+'px';
canvas.width = Math.round(W*dpr); canvas.height = Math.round(H*dpr);
ctx.setTransform(dpr,0,0,dpr,0,0);
stage.style.width = W+'px'; stage.style.height = H+'px';
hud.style.width = W+'px'; foot.style.width = W+'px';
buildBG(dpr);
}
```
Wait, availW limited to 980 — but at 1280 viewport, board could be bigger; 980 wide with aspect 26/22 → H = 830 > available (~640) so height constrains anyway: availH≈ 800 - (say 90) - 34 - 16 = 660 → cell = min(980/26=37.7, 660/22=30) → 30, capped 34 ok → board 780×660. Hmm 660 tall board + 90 header = 750, fits 800. Cell 30 is chunky-nice. Maybe cap cell at 30 to keep elegance: cap 32. Let's cap 32. Board 832×704 at big screens — that plus header 90 = 794 + footer 34 → 828 > 800! Need care: availH computed with actual hud/footer heights. availH = innerHeight(800) - hud(?) - footer(?) - paddings(≈40). If hud is ~64 and footer ~28: 800-64-28-40=668 → cell = min(980/26=37.7, 668/22=30.4) = 30 → board 780×660. Total 660+64+28+40 = 792 ≤ 800. OK tight but works. Add a couple px margins; padding 12 top/bottom. Let me use paddings: #app padding 10px; gaps 10px each: overhead = 20 + 2*10 + ... I'll compute availH = innerHeight - hudH - footH - 46. Good.
But careful: layout() depends on hud.offsetHeight which depends on width... circular-ish but roughly stable. Use initial estimate, then call layout again after setting width? HUD height shouldn't change much. Also on resize it recalcs. Acceptable. Also chips wrap could change height at narrow widths → recalc twice: call layout, then if hud height changed call again? Simple: run layout twice. Or measure with current width then clamp. I'll just call layout() then requestAnimationFrame(layout) once more on resize (cheap). Eh, minor. Keep single call; ensure hud doesn't wrap weirdly by design (flex-wrap: wrap — fine).
Fonts sizes: title clamp(20px, 3.2vw, 26px); chips font 13px, values 16-18 monospace.
Overlay styling:
```
.overlay { position:absolute; inset:0; display:flex; align-items:center; justify-content:center; background: rgba(6,10,8,0.25); opacity:0; transition: opacity .35s; pointer-events:none; }
.overlay.show { opacity:1 }
.card { background: rgba(10,16,13,0.72); backdrop-filter: blur(8px); border:1px solid rgba(134,239,172,0.18); border-radius:18px; padding: 22px 30px; text-align:center; box-shadow: 0 20px 50px rgba(0,0,0,.45); transform: translateY(6px) scale(.98); transition: transform .35s; }
.overlay.show .card { transform:none }
```
Game over overlay content: "GAME OVER" heading (red-ish accent?), score huge monospace, "BEST" line, hint "Space / Enter — restart". Add small divider.
Start overlay: "SNAKE" big title, "Watch the demo or jump in", pill button-looking "Click or press a key to start", tiny keys row.
Pause overlay: "PAUSED", "Press P to resume" (+ "click also resumes"? I'll implement click resume in paused mode; mention "P or click — resume").
Also add a subtle CSS animation on the start pill: pulsing border glow.
kbd styling: background rgba(255,255,255,0.06); border:1px solid rgba(255,255,255,0.12); border-bottom-width:2px; radius 6px; padding 2px 7px; font 11px monospace.
Now key handling code:
```
const KEY_DIRS = { ArrowUp:U, KeyW:U, ArrowDown:D, KeyS:D, ArrowLeft:L, KeyA:L, ArrowRight:R, KeyD:R };
window.addEventListener('keydown', e => {
if (e.metaKey||e.ctrlKey||e.altKey) return;
const code = e.code;
if (mode === 'demo'){
if (!['ShiftLeft','ShiftRight','ControlLeft','ControlRight','AltLeft','AltRight','MetaLeft','MetaRight','CapsLock'].includes(code)){
e.preventDefault(); startGame(); return;
}
return;
}
if (code === 'KeyP'){
e.preventDefault();
if (mode==='playing') pauseGame(); else if (mode==='paused') resumeGame();
return;
}
if ((code==='Space' || code==='Enter')){
e.preventDefault();
if (mode==='over'){ if (overFrom==='playing') startGame(); else startGame(); } // restart same
else if (mode==='paused') resumeGame();
return;
}
const d = KEY_DIRS[code];
if (d){
e.preventDefault();
if (mode==='paused'){ resumeGame(); }
if (mode!=='playing') return;
enqueue(d);
}
});
document.addEventListener('pointerdown', ()=>{ if(mode==='demo') startGame(); else if(mode==='paused') resumeGame(); });
```
Hmm: Space during 'over' from demo: restart as demo (or start playing?). If mode over & overFrom demo — a keypress... but in 'over' state after demo crash, keydown handler checks mode==='demo' — no, mode is 'over'. So pressing a key then wouldn't start playing! Fix: if mode==='over' && overFrom==='demo', any direction/space/enter/click → startGame() (playing). Let me handle: in keydown, first: if (mode==='over' && overFrom==='demo' && not modifier) → start playing. Or simpler: make demo's game-over not enter 'over' state visually... The auto-restart happens in 1.4s anyway; also allow immediate takeover. I'll add that branch.
Also Space in paused resumes? Spec says P pauses; resume via P — I'll allow Space/Enter to resume too (harmless). Actually spec: "Space or Enter restarts" (after game over). Resume pause: P. I'll keep Space also resuming — no wait, that could surprise. Keep: P resumes; Space/Enter in paused → resume too (friendly). Fine, I'll allow both and display "P — resume".
enqueue:
```
function enqueue(d){
const ref = queue.length ? queue[queue.length-1] : dir;
if (d.x === ref.x && d.y === ref.y) return; // duplicate
if (d.x === -ref.x && d.y === -ref.y) return; // reverse
if (queue.length >= 3) return; // or shift? ignore
queue.push(d);
}
```
Hmm "Quick successive key presses must both be honored" — with cap 3, pressing 3 dirs within one tick: all queued, applied over next ticks. Good.
Also queue should clear on reset.
Now step():
```
function step(){
if (mode==='demo'){ const d = aiDir(); if (d) dir = d; }
else if (queue.length) dir = queue.shift();
prevBody = body.map(p=>({x:p.x,y:p.y}));
const head = {x: body[0].x + dir.x, y: body[0].y + dir.y};
const eating = head.x===food.x && head.y===food.y;
// wall
if (head.x<0||head.y<0||head.x>=COLS||head.y>=ROWS){ die(); return; }
// self: check against body; tail cell vacates unless eating
const lim = eating ? body.length : body.length - 1;
for (let i=0;i<lim;i++){ if (body[i].x===head.x && body[i].y===head.y){ die(); return; } }
body.unshift(head);
if (eating){ onEat(); } else { body.pop(); }
}
```
Wait prevBody snapshot: after unshift/pop, prevBody holds old positions array (length N). body now N or N+1. For interpolation index i: from = prevBody[i] if exists else prevBody[last]. For non-eat move: body[i] = prevBody[i-1] ✓. For eat: body = [head, ...old], body[i]=prevBody[i-1] for i>=1, body[N] = prevBody[N-1] and its from = prevBody[N]?? doesn't exist → use prevBody[N-1] → same → stationary tail during growth tick ✓ (tail stays while head extends — correct growth behavior).
die():
```
function die(){
mode='over'; deathAt=performance.now(); deathDir={...dir};
if (overFrom==='playing'){ if (score>best){best=score; bestEl...}; }
...
}
```
Wait overFrom should be set: overFrom = (was this run demo?) — track a run flag `isDemoRun`. Set at reset. die() reads it. Then schedule: if isDemoRun → setTimeout(resetGame(true), 1600) guarded by token; else setTimeout(showOver overlay, 600).
Also for demo deaths, maybe show a quick "AI crashed — restarting" tiny message? The start overlay is always shown in demo anyway (it stays). At demo death the overlay remains "Click or press a key to start" and the game auto-restarts behind.
Hmm — should the start overlay remain visible during the whole demo? Yes, "behind a Click or press a key to start message". Keep it visible but make it not obscure too much. Card compact.
Also during 'over' from playing: overlay "GAME OVER — score X — best Y — press Space". ✓.
onEat():
```
score += 10; scoreEl.textContent = score; pop animation; eaten++;
tickMs = Math.max(68, BASE_TICK - eaten*2.4);
spawnParticles(food center); ring; popup '+10';
// new food:
food = spawnFood();
if (!food){ /* snake filled board — win! */ }
```
Win condition: if no free cell → treat as victory: show overlay "YOU WIN"? Edge case, include for robustness: if free cells 0 → mode='over' with a "PERFECT!" overlay variant. Rare, fine.
spawnFood:
```
const occ = new Set(body.map(c=>c.x+','+c.y));
const free=[];
for x,y: if !occ.has → push
if !free.length return null
return free[(Math.random()*free.length)|0]
```
Also maybe avoid spawning directly in front of head-adjacent? No, standard random is fine. But avoid spawning within 1 cell of head? Not required. Keep pure random.
Particles:
```
function spawnParticles(cx,cy){
for (let i=0;i<18;i++){
const a = Math.random()*Math.PI*2, sp = (Math.random()*0.5+0.15) * cell*0.09;
particles.push({x:cx,y:cy,vx:Math.cos(a)*sp,vy:Math.sin(a)*sp - cell*0.02,life:0,max:420+Math.random()*280, size: cell*(0.06+Math.random()*0.09), color: pick(['#ff6b6b','#ffa94d','#a3e635','#fbd38d'])});
}
}
```
Velocities in px per ms: sp like 0.05–0.15 px/ms → over 400ms travels 20–60px. Scale with cell.
Update: x += vx*dt; vy += gravity*dt; vx *= drag. Render alpha = 1 - life/max, maybe ease.
Popups: {x,y,text,life,max:800}: y rises, alpha fades, font bold `${cell*0.6}px` monospace with stroke? Fill white/green with slight shadow.
Rings: {x,y,life,max:450}: r = ease(life/max) * cell*1.4 + cell*0.3; alpha (1-p)*0.6; stroke '#ffd43b' or food color; lineWidth 3 taper.
Now AI flood fill:
```
function aiDir(){
const options = [];
const head = body[0];
for (const d of [R,L,U,D... order maybe food-biased]){
if (d.x===-dir.x && d.y===-dir.y) continue;
const nx=head.x+d.x, ny=head.y+d.y;
if (nx<0||ny<0||nx>=COLS||ny>=ROWS) continue;
const eating = (nx===food.x&&ny===food.y);
const blocked = new Set();
const lim = eating? body.length : body.length-1;
for (let i=0;i<lim;i++) blocked.add(body[i].x+','+body[i].y);
if (blocked.has(nx+','+ny)) continue;
const area = flood(nx,ny,blocked);
const dist = Math.abs(nx-food.x)+Math.abs(ny-food.y);
options.push({d, area, dist});
}
if (!options.length) return null;
// score
let best=null,bs=-1e9;
for (const o of options){
let s = 0;
// survival first
if (o.area >= body.length) s += 1e6; else s += o.area*100;
s -= o.dist*10;
s += Math.random()*2;
if (s>bs){bs=s;best=o.d;}
}
return best;
}
```
flood(nx,ny,blocked): BFS counting reachable cells (including start). Implementation with typed arrays:
```
function flood(sx,sy,blocked){
const seen = new Uint8Array(COLS*ROWS);
const q=[sx+sy*COLS]; seen[q[0]]=1; let count=0;
while(q.length){
const idx=q.pop(); count++;
const x=idx%COLS, y=(idx/COLS)|0;
for each of 4 neighbors: if in bounds && !seen && !blocked.has(x+','+y) → seen=1, push
}
return count;
}
```
Blocked as Set of strings — fine. Note: flood should not count food cell as blocked (food cell is free — when eating, the head lands on food; when passing near, food is free space). Food cell is not in blocked (body only). But we flood from the NEXT head position: if eating, next head IS the food cell — blocked set includes full body (tail stays), and food cell itself isn't blocked (food is at nx,ny; blocked only body). OK.
One subtlety: area >= body.length is a decent safety heuristic. Also add: prefer moves where area >= body.length; among safe, minimize dist; if none safe, maximize area. The scoring above approximates: 1e6 dominates; else area*100 vs dist*10 — dist up to ~48*10=480; area*100 up to 57200 — so unsafe options ranked purely by area. Hmm if no option has area >= len, best = max area. OK. But among "safe" (area>=len), ranking = 1e6 - dist*10 → greedy toward food. Add slight lookahead randomness for organic movement: + random*2 too small vs dist*10; fine, path is deterministic-ish — looks fine.
Potential demo AI weakness: it might oscillate/hug its tail when safe areas equal? dist tiebreak prevents.
This AI is decent — will routinely reach 100-300 points. Good demo.
Demo speed: use BASE_TICK 120 for demo? Same ramp. Fine.
Now render snake details.
pts computation:
```
function snakePoints(t){
const pts=[];
for (let i=0;i<body.length;i++){
const from = prevBody[i] || prevBody[prevBody.length-1] || body[i];
const to = body[i];
pts.push({ x:(from.x+(to.x-from.x)*t+0.5)*cell, y:(from.y+(to.y-from.y)*t+0.5)*cell });
}
return pts;
}
```
At reset: prevBody = copy of body → stationary until first tick. But note first tick occurs lastTick=now; t small. Good.
Draw order: food (behind snake? snake passes over food cell as it eats — draw food first, then snake covers it as head arrives — natural). Particles on top.
Head direction for eyes: use interpolated vector pts[0]-pts[1] normalized; fallback dir.
Actually there's an issue: during the very first ticks body length 4 all in a row horizontally — fine.
Death bump: in render, if mode==='over':
```
const ddt = now - deathAt;
const bump = Math.sin(Math.min(ddt/280,1)*Math.PI) * 0.32 * cell;
pts[0].x += deathDir.x*bump; pts[0].y += deathDir.y*bump;
```
Wait but at death, t should be 1 (body completed last committed move). If die() happens during step at tick boundary, prevBody/body were NOT updated (we returned before unshift) — body is still the pre-move state and prevBody is from the previous tick — so interpolating with t∈[0..1] normal continues until clamped t=1, which equals body (pre-move). So the snake freezes at its last full cell — no partial motion pending. But t could have been mid-interpolation when die happened (e.g., tick happens while t... no: step() is called at tick boundaries, meaning t just hit 1 and resets to 0 as prevBody=body snapshot then move commits... wait sequence: lastTick accumulates; when elapsed>=tickMs, step(): prevBody=snapshot(current body) then body moves. So right after step, t≈0 and interpolation runs prevBody→body over the next tickMs. If die(): prevBody was re-snapshotted (die called after snapshot — yes prevBody = snapshot at top of step, then die() returns before modifying body). So after death: prevBody === body (same cells!) → interpolation static regardless of t. Head sits at the last cell. Then bump animation pushes head toward the wall/itself 0.32 cell and back.
For wall death: head bumps toward wall — nice. For self death: bumps toward its own body — also fine (bonks into itself).
Also flash: draw a red-ish overlay on the head & first segments for 300ms: simplest: draw head circle normally, then overlay arc fill 'rgba(255,80,80,alpha)'. Also whole snake: after body strokes, if ddt<300: ctx.globalCompositeOperation... simpler: redraw the first path stroke (single stroke over all pts) with rgba(255,90,90, 0.35*(1-ddt/300)). Good effect.
Shake: apply translate before drawing everything (after clear/bg? shake the board content including bg looks best — bg drawn inside translate too, edges could reveal gap; draw bg slightly overscanned or fill page color beneath. Simpler: shake only the snake/food/particles? Shaking everything with overscan: fillRect page-bg color full canvas first (no translate), then translate and draw bg image — bg image is same size as canvas; shaking reveals edges up to ~6px showing page color — acceptable dark. I'll do: ctx.fillStyle page bg fill whole; save; translate(shake); draw bg image; ... restore.
Shake magnitude: mag = max(0, 1 - ddt/320) * 5; offset = (Math.random()*2-1)*mag both axes. Random per frame — jittery good.
Also small "eat" feedback on canvas: brief brightness — skip.
Score chip pop: CSS keyframe scale. Re-trigger: el.classList.remove('pop'); void el.offsetWidth; el.classList.add('pop').
New best live: if score>best && mode playing → scoreChip gets 'record' class (glowing). On game over remove.
Now overlays visibility management:
```
function setOverlay(){
startOv.classList.toggle('show', mode==='demo');
pauseOv.classList.toggle('show', mode==='paused');
overOv.classList.toggle('show', mode==='over' && overFrom==='playing' && overlayReady);
}
```
Simplify: show over overlay via the setTimeout only. And start overlay show when mode==='demo'. Since demo auto-restart sets mode='demo' again, overlay persists — but it never got hidden, fine.
Wait: demo death sets mode='over' — then start overlay hidden (since mode not demo) for 1.6s then reappears. Slight blink of the start card — actually gives a nice beat showing the crash. Hmm, might be better to keep the start overlay visible during demo crash — the overlay hides, you see the crash clearly, then it returns. I think it's fine and even nice.
But careful: during that 1.6s window, mode==='over', overFrom==='demo' — the "GAME OVER" overlay must NOT show. Guard with overFrom. ✓ And keydown/click during that window → startGame() (playing). Handle in keydown: `if (mode==='over' && overFrom==='demo') { start... }` before the space/enter branch. Also pointerdown: if mode==='over' && overFrom==='demo' → startGame().
Hmm, actually maybe simpler: demo death → immediately resetGame(true) after 1.4s, but between, mode could just stay 'demo'?? If I keep mode 'demo' but freeze ticking... complexity. Keep as planned.
restart token: `let demoTimer=null; let overTimer=null;` die(): clear both; if demo: demoTimer=setTimeout(()=>{demoTimer=null; if(mode==='over'&&overFrom==='demo') resetGame(true);},1500) else overTimer=setTimeout(()=>{overTimer=null; if(mode==='over') showOverOverlay()}, 600). startGame(): clear timers.
showOverOverlay: overOv.classList.add('show'); fill score values.
Also best display update at death.
Pause: pausedAt=performance.now(). Resume: lastTick += now - pausedAt. Also particles/popups freeze during pause — their life is timestamp-based; pausing would let them fade out during pause. Minor: particles freeze visually only if we don't update... they're updated each frame with dt — during pause they'd keep animating (fade out) — that's fine/harmless. Actually during pause we still render and update particles — okay. Or skip particle updates when paused (freeze them) — nicer: skip updates when paused; they'll resume. Do: if mode!=='paused' update effects. But dt accumulates... store lastFrame always, compute dt, but only apply updates when not paused. On resume dt spike? dt = now-lastFrame each frame, so no spike since lastFrame updates every frame. ✓.
Pause also stops interpolation via t from pausedAt. ✓.
Visibility/tab: if hidden during play, rAF stops; on return, elapsed large → I reset lastTick = now (skip forward). Implement at top of frame: if (now - lastTick > 1000) lastTick = now - 0; (only when playing/demo). Actually put inside the tick loop: before while: if (now-lastTick > tickMs*4) lastTick = now - tickMs; hmm simpler: if (now - lastTick > 1000) lastTick = now. ✓.
Now HTML/CSS writing. Also add a favicon? Not needed; skip (data URI could avoid 404 console error — browsers request favicon only for top-level; in iframe no. Fine.)
Title: "Snake".
Small extra flavor: page background subtle radial + maybe animated hue? Keep static.
Let me write the HUD:
```html
<header id="hud">
<div class="brand">
<svg ...snake mark...></svg>
<h1>SNAKE</h1>
</div>
<div class="stats">
<div class="chip" id="scoreChip"><span class="label">Score</span><span class="value" id="scoreVal">0</span></div>
<div class="chip" id="bestChip"><span class="label">Best</span><span class="value" id="bestVal">0</span></div>
<div class="chip" id="demoChip"><span class="label">AI demo</span><span class="live"></span></div>
</div>
</header>
```
Length chip? I said maybe. Keep Score, Best, and demo indicator. Fine (length is visible as the snake itself).
Footer:
```html
<footer id="foot">
<span class="hint"><kbd>W</kbd><kbd>A</kbd><kbd>S</kbd><kbd>D</kbd> / <kbd>↑</kbd><kbd>↓</kbd><kbd>←</kbd><kbd>→</kbd> move</span>
<span class="hint"><kbd>P</kbd> pause</span>
<span class="hint"><kbd>Space</kbd> restart</span>
</footer>
```
Demo chip: show only in demo mode: CSS class on body or direct toggle.
Now, careful about starting the game from a click while the click lands on overlay (pointer-events:none → passes through). Document-level pointerdown works regardless.
Also add touch swipe support? Not required; small bonus. Pointer events: could add swipe detection for mobile. Requirement mentions arrows/WASD only. Add basic swipe: pointerdown records, pointermove/up computes delta → direction; also tap to start. Cheap to add, improves mobile demo judging. I'll add simple swipe on the stage: track pointerdown position; on pointerup, if moved > 24px → enqueue dominant axis direction; if it was a tap (<10px) → start/pause-toggle? Taps start game in demo / resume in pause; during play, tap does nothing (or pause? no). OK add it.
But careful: pointerdown listener for "start on click" also triggers on swipe start — fine (tap requirement: start on any pointerdown — spec says click or key; starting on pointerdown is fine).
Swipe should use click-style: on pointerdown during playing, record; pointerup compute. Use stage element listeners with `touch-action: none` on canvas to prevent scroll (page shouldn't scroll anyway).
Let me now also double check "smooth movement" — interpolation covers it. Also maybe motion smoothing easing? Linear is correct for constant speed feel.
Now colors & exact drawing code for the snake:
```
const HEAD_C = [126, 240, 140]??
```
Let me define palette:
- head fill: `#8ff0a4`? and body gradient tail `#1e6f46` → mid `#3fae6a` → head `#6fe08d`. Eyes white with dark pupils `#10241a`.
- Outline/underlay: `rgba(8, 24, 16, 0.5)` width cell*0.9? Underlay gives the snake a subtle dark rim separating it from board. Actually rim: stroke path with darker green `#14532d` width slightly larger before main strokes: width cell*0.8+4? Let me: rim width = cell*0.84, color `#123f28`; body width cell*0.68. Then per-segment colored strokes at cell*0.66..0.72. Hmm rim as single full-path stroke (uniform) then per-segment bodies inside. Good.
- Glow pass: shadowBlur 16, color rgba(110,231,183,0.4), draw rim stroke first with shadow → glow halo. Then shadow off.
Let me order:
1. ctx.save(); ctx.shadowColor='rgba(74,222,128,0.45)'; ctx.shadowBlur=cell*0.6; stroke full path (rim color, width cell*0.8) — this gives outline+glow. restore.
2. Per-segment main strokes (tail→head color lerp, width lerp cell*0.62→0.7).
3. Optional thin top highlight: full path stroke width cell*0.28, color rgba(255,255,255,0.07), offset slightly up-left? Stroke can't offset easily; use second path with points shifted (-cell*0.14, -cell*0.18)? That would misalign at turns. Skip or draw dashed center line? Add subtle center line: stroke full path width cell*0.16 color rgba(0,60,30,0.25) — gives depth like a spine. Try it: could look nice. I'll add with low alpha.
4. Head circle + eyes + tongue.
Head circle radius: cell*0.42 (slightly smaller than body half-width 0.35? body width 0.7 → radius 0.35; head 0.42 bigger ✓ distinct).
Eyes: r_e = cell*0.11; positioned: forward = dir*r_head*0.30; side = perp*r_head*0.44. eye center = head + forward ± side... hmm both eyes: head + forward + side, head + forward - side. White circles r cell*0.12; pupils r cell*0.055 at eyeCenter + dir*cell*0.045. Add tiny white glint on pupil: r cell*0.02 offset. Blink: draw eyes as flattened: scale y? When blinking draw line instead: stroke short line perpendicular... Simplify blink: draw pupil only (closed lids)? I'll draw eyes with scaleY 0.15 during blink using ellipse(). ctx.ellipse supports radii — great: ellipse(x,y,rx,ry,rot,0,2π). Normal ry = rx; blink ry = rx*0.15.
Tongue: when ((now % 2800) < 240) and mode playing/demo: p = phase/240; ext = Math.sin(p*Math.PI); base at head + dir*r_head*0.95; tip = base + dir*ext*cell*0.55; fork: two tips at tip ± perp*cell*0.12 + dir*cell*0.1. Draw with lineWidth cell*0.07, color '#ff5d7e', round caps: path base→tipmid→fork1, tipmid→fork2. Where tipmid = base + dir*ext*cell*0.4. Only draw when not paused? During pause it's frozen anyway if we gate tongue by mode? now keeps running so tongue animates during pause behind overlay — harmless (blurred). Fine.
Food draw:
```
const fx=(food.x+0.5)*cell, fy=(food.y+0.5)*cell;
const pulse = 1 + Math.sin(now/280)*0.07;
const r = cell*0.36*pulse;
// glow
ctx.save(); ctx.shadowColor='rgba(255,90,90,0.55)'; ctx.shadowBlur=cell*0.8;
ctx.fillStyle='#ff5a5a'; circle(fx,fy+cell*0.02, r); fill; restore
// gradient body: radial from (fx-r*0.35, fy-r*0.4): '#ffb3ab' → '#e23b3b'
// highlight: small white ellipse alpha .55 at upper-left
// stem: brown line from top
// leaf: green ellipse rotated
```
Apple shape more apple-like: two overlapping circles? Keep single circle + dimple? Fine with circle.
Also food spawn animation: scale-in when new food appears: store foodSpawnAt; scale = easeOutBack(min((now-foodSpawnAt)/250,1)). Nice.
Score popup font: `bold ${cell*0.55}px ui-monospace...`, color '#c7f9d9'? Use accent '#b7f7cd' with shadow. Rising by cell*0.9 over life, alpha out quadratic.
Now the demo "AI" pill: `.live` = pulsing dot (CSS animation, red/green?). Use amber dot with pulse: "AI demo". Hidden unless demo.
Also brand SVG mark: small 22px snake glyph — I'll draw a simple S-curve path with round caps:
```html
<svg viewBox="0 0 24 24" width="26" height="26"><path d="M4 16c0-3 3-4 6-4s6-1 6-4-3-4-6-4" fill="none" stroke="url(#g)" stroke-width="4" stroke-linecap="round"/><circle cx="18" cy="16" r="3" fill="#a7f3d0"/></svg>
```
Hmm let me craft: a curved snake body with head dot. Keep something simple: path "M5 18 C5 13, 11 15, 12 11 C13 7, 19 8, 19 5" stroke green gradient + circle at end (head) + tiny eye? Fine — decorative. I'll hand-tune-ish; it's small so any rounded squiggle reads as a snake. Add defs linearGradient green. OK.
Now, CSS full:
```css
* { box-sizing: border-box; margin:0; padding:0; }
html,body { height:100%; }
body {
background: #0a0f0c radial-gradient(1200px 700px at 50% 12%, rgba(74,222,128,.07), transparent 60%);
color:#e7f4ea;
font-family: ui-sans-serif, system-ui, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
overflow:hidden;
display:flex; align-items:center; justify-content:center;
height:100dvh;
}
#app { display:flex; flex-direction:column; align-items:center; gap:12px; padding:12px; }
#hud { display:flex; align-items:center; justify-content:space-between; gap:14px; flex-wrap:wrap; }
.brand { display:flex; align-items:center; gap:10px; }
h1 { font-size: clamp(18px, 2.4vw, 24px); letter-spacing: .35em; font-weight: 800; color: transparent; background: linear-gradient(90deg,#b9f6ca,#4ade80 55%, #1f7a4d); -webkit-background-clip:text; background-clip:text; }
.stats { display:flex; gap:8px; align-items:stretch; }
.chip { display:flex; flex-direction:column? }
```
Chip design: small rounded rect with label above value? Horizontal space fine: chip = {label small uppercase muted; value mono bold}. Two lines: label 9px letterspaced, value 16px mono. chip padding 6px 12px; bg rgba(255,255,255,.04); border 1px rgba(255,255,255,.08); radius 10px; min-width 74px; text-align center.
Record glow: .chip.record { border-color: rgba(183,247,205,.7); box-shadow: 0 0 18px rgba(74,222,128,.25); } with value color accent.
Pop keyframe: scale 1→1.18→1 on .value.pop.
Demo chip: amber: label "AI demo", dot pulsing. `.dot { width:7px;height:7px;border-radius:50%;background:#fbbf24; animation: blink 1.2s infinite }` hidden by default (display none), shown when body.demo. Actually I toggle a class on the chip directly.
Canvas: `#stage { position:relative; filter: drop-shadow(0 24px 60px rgba(0,0,0,.5)); }` — drop-shadow filter on container may be expensive? fine. Or box-shadow on canvas. Use box-shadow on canvas: `box-shadow: 0 0 0 1px rgba(134,239,172,.16), 0 30px 70px rgba(0,0,0,.55), 0 0 90px rgba(74,222,128,.07); border-radius: 16px;`
Overlays: as planned.
Footer: font-size 11.5px; color #86a892; gap 14px; flex wrap center. kbd styling.
Also on small screens hide some kbd hints? Keep, they wrap.
Media query <520px: h1 letter-spacing .2em; chips smaller.
Now overlays content:
Start:
```html
<div class="overlay show" id="ovStart">
<div class="card">
<div class="ov-kicker">Classic arcade</div>
<div class="ov-title">SNAKE</div>
<div class="ov-sub">A little snake plays for you right now.</div>
<div class="cta"><span class="cta-pulse"></span>Click or press a key to start</div>
<div class="ov-keys"><kbd>↑↓←→</kbd> or <kbd>WASD</kbd> to steer</div>
</div>
</div>
```
CTA pill: border accent, pulse animation glow.
Pause:
```html
<div class="overlay" id="ovPause">
<div class="card">
<div class="ov-title sm">PAUSED</div>
<div class="ov-sub">Press <kbd>P</kbd> or click to resume</div>
</div>
</div>
```
Over:
```html
<div class="overlay" id="ovOver">
<div class="card">
<div class="ov-title sm danger">GAME OVER</div>
<div class="final"><span class="fl-label">Score</span><span class="fl-value" id="finalScore">0</span></div>
<div class="ov-sub" id="finalBestLine">Best of session: <b id="finalBest">0</b></div>
<div class="cta">Press <kbd>Space</kbd> or <kbd>Enter</kbd> to restart</div>
<div class="newbest" id="newBestTag" hidden>New session best!</div>
</div>
</div>
```
Order: put newBest tag near score. Show when score===best && score>0 at death.
Now JS full code. Let me carefully write it.
```js
(() => {
'use strict';
// ---- constants
const COLS = 26, ROWS = 22;
const BASE_TICK = 132, MIN_TICK = 68, TICK_STEP = 2.4;
const DIRS = { up:{x:0,y:-1}, down:{x:0,y:1}, left:{x:-1,y:0}, right:{x:1,y:0} };
// dom
const canvas = document.getElementById('game');
const ctx = canvas.getContext('2d');
const stage=..., hud=..., foot=...
const scoreVal, bestVal, chips...
```
Key mapping via e.code: 'ArrowUp','KeyW' → up etc.
Game state vars:
```js
let cell = 24, W=0, H=0, dpr=1;
let bg=null;
let body=[], prevBody=[], dir=DIRS.right, queue=[];
let food={x:10,y:5}, foodAt=0;
let mode='demo', overFrom='demo', isDemoRun=true;
let score=0, eaten=0, best=0, tickMs=BASE_TICK;
let lastTick=0, pausedAt=0, deathAt=0, deathDir={x:1,y:0};
let particles=[], rings=[], pops=[];
let demoTimer=0, overTimer=0, lastFrame=0;
```
resetGame(demo):
```js
function resetGame(demo){
isDemoRun = demo;
mode = demo ? 'demo' : 'playing';
clearTimeout(demoTimer); clearTimeout(overTimer);
demoTimer = overTimer = 0;
const cy = (ROWS/2)|0;
body = [];
for (let i=0;i<4;i++) body.push({x: 12 - i, y: cy});
prevBody = body.map(c=>({...c));
dir = DIRS.right; queue.length=0;
score = 0; eaten = 0; tickMs = demo ? 118 : BASE_TICK;
particles.length=0; rings.length=0; pops.length=0;
placeFood();
lastTick = performance.now();
scoreVal.textContent = '0';
scoreChip.classList.remove('record');
document.body.classList.toggle('demo', demo);
ovStart.classList.toggle('show', demo);
ovPause.classList.remove('show');
ovOver.classList.remove('show');
}
```
Wait ovStart show should follow mode==='demo' — resetGame covers both demo restart and player start. ✓. body.demo class toggles demo chip visibility. Note on death from playing → body.demo false (already). When player starts, demo chip hides. ✓
startGame() = resetGame(false).
placeFood():
```js
function placeFood(){
const occ = new Set(body.map(c=>c.x+','+c.y));
const free=[];
for(let y=0;y<ROWS;y++) for(let x=0;x<COLS;x++){ const k=x+','+y; if(!occ.has(k)) free.push({x,y}); }
if(!free.length){ food=null; winGame(); return; }
food = free[(Math.random()*free.length)|0];
foodAt = performance.now();
}
```
step():
```js
function step(){
if (mode==='demo'){ const d = aiDir(); if(d) dir = d; }
else if (queue.length) dir = queue.shift();
prevBody = body.map(c=>({x:c.x,y:c.y}));
const nh = {x: body[0].x+dir.x, y: body[0].y+dir.y};
if (nh.x<0||nh.y<0||nh.x>=COLS||nh.y>=ROWS){ die(); return; }
const eating = food && nh.x===food.x && nh.y===food.y;
const lim = eating ? body.length : body.length-1;
for (let i=0;i<lim;i++){ if(body[i].x===nh.x && body[i].y===nh.y){ die(); return; } }
body.unshift(nh);
if (eating) onEat(); else body.pop();
}
```
die():
```js
function die(){
mode='over'; overFrom = isDemoRun?'demo':'playing';
deathAt = performance.now(); deathDir = {x:dir.x,y:dir.y};
queue.length=0;
if (overFrom==='demo'){
demoTimer = setTimeout(()=>{ demoTimer=0; if(mode==='over') resetGame(true); }, 1600);
} else {
const isNew = score>best;
if (isNew) best=score;
bestVal.textContent = best;
scoreChip.classList.remove('record');
overTimer = setTimeout(()=>{ overTimer=0;
finalScore.textContent=score; finalBest.textContent=best;
newBestTag.hidden = !(isNew && score>0);
ovOver.classList.add('show');
}, 650);
}
}
```
Hmm during the 650ms overlay delay, mode==='over' and overFrom==='playing' — pressing Space restarts (keydown branch checks mode==='over' → restart, regardless of overlay). ✓.
winGame(): mode='over', overFrom='playing', show over overlay with title "PERFECT!"? Simplify: reuse over overlay but change title text: I'll add element ovOverTitle and set text 'YOU WIN!' vs 'GAME OVER'. Rare but nice. Actually keep 'GAME OVER' hmm — winning showing "GAME OVER" is wrong. Add the title element swap. Minimal code.
onEat():
```js
function onEat(){
score += 10;
eaten++;
tickMs = Math.max(MIN_TICK, (isDemoRun?118:BASE_TICK) - eaten*TICK_STEP);
scoreVal.textContent = score;
popEl(scoreVal);
if (mode==='playing' && score>best) scoreChip.classList.add('record');
const fx=(food.x+0.5)*cell, fy=(food.y+0.5)*cell;
burst(fx,fy);
rings.push({x:fx,y:fy,life:0,max:420});
pops.push({x:fx,y:fy,life:0,max:800,text:'+10'});
placeFood();
}
```
burst():
```js
function burst(x,y){
const colors=['#ff6b6b','#ffb35c','#a3e635','#ff8fa3','#ffd43b'];
const n=16;
for(let i=0;i<n;i++){
const a=(i/n)*Math.PI*2 + Math.random()*0.5;
const sp=(0.04+Math.random()*0.11)*cell/16*?
```
Let me define speed in px/ms relative to cell: v = cell*(0.02+Math.random()*0.06) px/ms → for cell 28: 0.56–1.7 px/ms → over 500ms with drag... a bit fast; try cell*(0.012+rand*0.045). Over life 500ms with drag factor per ms ~0.998^dt... Let me just do: vx = cos(a)*sp, vy = sin(a)*sp - cell*0.02 (upward bias); update: p.x+=p.vx*dt; p.vy += cell*0.00012*dt (gravity); p.vx *= Math.pow(0.998, dt)... use exp decay: `const f = Math.exp(-dt/280); vx*=f; vy = vy*f + cell*0.00018*dt;` tune later. Visual: particles arc slightly and fade. Sizes cell*0.05–0.12 circles; some as small squares rotated? circles fine; maybe mix circles and 'spark' lines. Keep circles with glow? shadowBlur per particle is costly but 16 particles fine. Use shadowBlur cell*0.35, color per particle? shadow color same as fill — set per particle, fine for 16.
Simplify performance: draw with globalAlpha, no shadow. On dark bg bright colors pop anyway. I'll add tiny glow via drawing slightly larger low-alpha circle behind. Meh — just draw; fine.
Update loop for effects (when not paused):
```js
function updateFx(dt){
for (let i=particles.length-1;i>=0;i--){
const p=particles[i]; p.life+=dt;
if(p.life>=p.max){ particles.splice(i,1); continue; }
const f=Math.exp(-dt/300);
p.vx*=f; p.vy=p.vy*f + cell*0.00045*dt;
p.x+=p.vx*dt; p.y+=p.vy*dt;
}
rings/pops similar life advance & filter.
}
```
gravity: cell*0.00045 px/ms² → over 500ms adds vy 0.0063*cell? tiny. Let me set gravity cell*0.0012*dt: after 400ms vy += 0.48*cell/s? ugh units. vy px/ms: gravity adds cell*0.0012 per ms → after 300ms vy += 0.36*cell px/ms?? That's huge (0.36*28=10 px/ms). No: gravity*dt where dt in ms: g = cell*0.00012 px/ms² → after 500ms velocity += 0.06*cell px/ms = 1.68 px/ms for cell 28 → 84px over 500ms. Still big. Hmm, total particle life ~450ms, initial speeds ~0.3-1.5 px/ms. Let me target: initial speed 0.05–0.18 px/ms (cell 28 → 1.4–5 px/ms?? no).
Redo with concrete numbers: I want particles to fly ~1–2 cells (28–56px) over ~450ms. Average speed ~0.08–0.12 px/ms. So sp range 0.04–0.20 px/ms, scaled by cell/28 for other sizes: sp = (0.04+Math.random()*0.16) * (cell/28). Gravity: adds ~0.1 px/ms over life → g = 0.0002 px/ms² * (cell/28). OK:
```js
const s = cell/28;
sp = (0.05+Math.random()*0.16)*s;
g = 0.00025*s;
```
Drag exp(-dt/350).
Fine — I'll eyeball; even if slightly off, looks fine.
Popups: p.y -= dt*0.035*s; alpha = 1-(life/max)^2.
Rings: r = cell*(0.3 + ease*1.1); alpha=(1-e)*0.7; lw = 3*(1-e)+1; color '#ffd43b'? Use apple red-orange '#ff8c69'? I'll use '#ffb35c' warm. Or green to match snake? Food-related warm. '#ff8f6b'.
drawFood with spawn easeOutBack:
```js
function easeOutBack(x){ const c1=1.70158,c3=c1+1; return 1+c3*Math.pow(x-1,3)+c1*Math.pow(x-1,2); }
```
Now render():
```js
function render(now){
let t;
if (mode==='paused') t = Math.min(1,(pausedAt-lastTick)/tickMs);
else if (mode==='over') t = 1;
else t = Math.min(1,(now-lastTick)/tickMs);
ctx.clearRect(0,0,W,H);
// base
ctx.save();
if (mode==='over'){
const dd = now-deathAt;
if (dd<340){ const m=(1-dd/340)*5; ctx.translate((Math.random()*2-1)*m,(Math.random()*2-1)*m); }
}
ctx.drawImage(bg,0,0,W,H);
if (food) drawFood(now);
drawSnake(now,t);
drawFx(now);
ctx.restore();
}
```
Note clearRect then drawImage bg covers all — clearRect optional. During shake translate, edges reveal cleared (transparent → canvas element background shows... canvas transparent shows page bg behind? The canvas has border-radius; behind it is page bg (#0a0f0c-ish) — transparent canvas shows body bg. Fine, dark on dark.
drawSnake(now,t):
```js
const n = body.length;
const pts=[];
for(let i=0;i<n;i++){
const from = prevBody[i] || prevBody[prevBody.length-1] || body[i];
const to = body[i];
pts.push({x:(from.x+(to.x-from.x)*t+0.5)*cell, y:(from.y+(to.y-from.y)*t+0.5)*cell});
}
// death bump
let bump=0;
if (mode==='over'){
const dd=now-deathAt;
if (dd<320) bump=Math.sin(Math.min(dd/320,1)*Math.PI)*0.30;
pts[0].x += deathDir.x*bump*cell; pts[0].y += deathDir.y*bump*cell;
}
// path helper
function pathThrough(list){ ctx.beginPath(); ctx.moveTo(list[0].x,list[0].y); for(let i=1;i<list.length;i++) ctx.lineTo(list[i].x,list[i].y); }
// glow + rim
ctx.save();
ctx.lineJoin='round'; ctx.lineCap='round';
ctx.shadowColor='rgba(74,222,128,0.5)'; ctx.shadowBlur=cell*0.7;
ctx.strokeStyle='#103722';
ctx.lineWidth=cell*0.82;
pathThrough(pts); ctx.stroke();
ctx.restore();
// body gradient segments tail→head
for(let i=n-1;i>0;i--){
const k=1-i/(n-1);
ctx.strokeStyle = bodyColor(k);
ctx.lineCap='round';
ctx.lineWidth = cell*(0.60+0.10*k);
ctx.beginPath(); ctx.moveTo(pts[i].x,pts[i].y); ctx.lineTo(pts[i-1].x,pts[i-1].y); ctx.stroke();
}
// spine hint
ctx.strokeStyle='rgba(6,34,20,0.35)'; ctx.lineWidth=cell*0.14; pathThrough(pts); ctx.stroke();
// death flash
if (mode==='over' && now-deathAt<300){
ctx.strokeStyle=`rgba(255,92,92,${0.45*(1-(now-deathAt)/300)})`;
ctx.lineWidth=cell*0.82; pathThrough(pts); ctx.stroke();
}
drawHead(pts, now);
```
bodyColor(k): lerp between tail rgb(24,84,52)? and head rgb(134,239,172):
tail: hsl(158,60%,26%) → rgb approx (27,106,74)? Let me compute: hsl(158,60%,26%): L=0.26, S=0.6 → C = (1-|2*0.26-1|)*0.6 = (1-0.48)*0.6=0.312; hmm easier to just use `hsl()` strings! `hsl(${152+8*k}, ${52+18*k}%, ${26+30*k}%)` — canvas supports hsl strings. tail k=0: hsl(152,52%,26%), head k=1: hsl(160,70%,56%).
Head fill: hsl(145, 76%, 62%)? distinct from first body segment (160,70%,56%) slightly. Or draw head as brighter: hsl(140,80%,66%). Add darker outline: stroke hsl(152,60%,30%).
drawHead:
```js
function drawHead(pts, now){
const hx=pts[0].x, hy=pts[0].y;
let dx=dir.x, dy=dir.y;
if (pts.length>1){
const vx=pts[0].x-pts[1].x, vy=pts[0].y-pts[1].y;
const m=Math.hypot(vx,vy)||1;
if(m>0.001){ dx=vx/m; dy=vy/m; }
}
// during death bump, direction stays deathDir-ish fine.
const r=cell*0.44;
ctx.fillStyle='hsl(140,78%,64%)';
ctx.strokeStyle='hsl(152,60%,26%)';
ctx.lineWidth=Math.max(1.5,cell*0.08);
ctx.beginPath(); ctx.arc(hx,hy,r,0,Math.PI*2); ctx.fill(); ctx.stroke();
// small top highlight
ctx.fillStyle='rgba(255,255,255,0.18)';
ctx.beginPath(); ctx.arc(hx-r*0.28,hy-r*0.32,r*0.42,0,Math.PI*2); ctx.fill();
// tongue
const tf = now % 2600;
if (mode!=='over' && tf<230){
const ext=Math.sin((tf/230)*Math.PI);
const bx=hx+dx*r*0.9, by=hy+dy*r*0.9;
const mx=bx+dx*ext*cell*0.42, my=by+dy*ext*cell*0.42;
const px=-dy, py=dx;
ctx.strokeStyle='#ff5d7e'; ctx.lineWidth=Math.max(1.4,cell*0.07); ctx.lineCap='round';
ctx.beginPath(); ctx.moveTo(bx,by); ctx.lineTo(mx,my);
ctx.lineTo(mx+dx*cell*0.14+px*cell*0.09, my+dy*cell*0.14+py*cell*0.09);
ctx.moveTo(mx,my);
ctx.lineTo(mx+dx*cell*0.14-px*cell*0.09, my+dy*cell*0.14-py*cell*0.09);
ctx.stroke();
}
// eyes
const px=-dy, py=dx;
const fw=cell*0.16, sd=cell*0.19;
const blink = ((now%3400)<110) ? 0.18 : 1;
for(const s of [1,-1]){
const ex=hx+dx*fw+px*sd*s, ey=hy+dy*fw+py*sd*s;
ctx.fillStyle='#f4fff6';
ctx.beginPath(); ctx.ellipse(ex,ey,cell*0.115,cell*0.115*blink,0,0,Math.PI*2); ctx.fill();
if(blink===1){
ctx.fillStyle='#12251b';
ctx.beginPath(); ctx.arc(ex+dx*cell*0.045,ey+dy*cell*0.045,cell*0.055,0,Math.PI*2); ctx.fill();
ctx.fillStyle='rgba(255,255,255,0.9)';
ctx.beginPath(); ctx.arc(ex+dx*cell*0.02-px*? ...)
```
Glint: small white dot offset: at ex+dx*0.06cell - px*0.03cell... fine.
Hmm eyes: during blink scale ry only → looks like closing lids. OK.
Note the eye ellipse rotation: eyes are circles so rotation irrelevant; blink squishes vertically in screen space, not relative to direction — for side-facing head it should squish along movement axis... Minor; blink is 110ms, acceptable. Could rotate ellipse by angle of dir: ctx.ellipse(ex,ey,rx along-perp?, ...). To squish along movement: rx (along x-axis after rotation) = r*blink when rotated to movement direction. ellipse(x,y,rx,ry,rotation): with rotation = atan2(dy,dx), rx is along movement dir: rx = cell*0.115*blink, ry = cell*0.115. When moving horizontally rotation 0 → squishes x — correct.
drawFood:
```js
function drawFood(now){
const grow = Math.min(1,(now-foodAt)/260);
const gs = grow<1 ? easeOutBack(grow) : 1;
const pulse = 1+Math.sin(now/300)*0.05;
const fx=(food.x+0.5)*cell, fy=(food.y+0.5)*cell;
const r=cell*0.34*gs*pulse;
// halo
ctx.save();
ctx.globalAlpha=0.35+0.15*Math.sin(now/300+1);
ctx.fillStyle='#ff5c5c'; ctx.shadowColor='rgba(255,92,92,0.8)'; ctx.shadowBlur=cell*0.9;
ctx.beginPath(); ctx.arc(fx,fy,r,0,7); ctx.fill();
ctx.restore();
// body gradient
const g=ctx.createRadialGradient(fx-r*0.4,fy-r*0.45,r*0.15,fx,fy,r*1.05);
g.addColorStop(0,'#ffb4a0'); g.addColorStop(0.45,'#f4563f'); g.addColorStop(1,'#c22e2e');
ctx.fillStyle=g; ctx.beginPath(); ctx.arc(fx,fy,r,0,7); ctx.fill();
// highlight
ctx.fillStyle='rgba(255,255,255,0.5)';
ctx.beginPath(); ctx.ellipse(fx-r*0.35,fy-r*0.4,r*0.22,r*0.13,-0.6,0,7); ctx.fill();
// stem
ctx.strokeStyle='#7a4a21'; ctx.lineWidth=Math.max(1.4,cell*0.06); ctx.lineCap='round';
ctx.beginPath(); ctx.moveTo(fx,fy-r*0.85); ctx.quadraticCurveTo(fx+r*0.15,fy-r*1.15,fx+r*0.35,fy-r*1.2); ctx.stroke();
// leaf
ctx.fillStyle='#57c98a';
ctx.beginPath(); ctx.ellipse(fx+r*0.55,fy-r*1.05,r*0.32,r*0.16,-0.5,0,7); ctx.fill();
}
```
arc(...,0,7): 7 > 2π ok.
drawFx:
```js
function drawFx(now){
for (const p of particles){
const a = 1-p.life/p.max;
ctx.globalAlpha=Math.max(0,a);
ctx.fillStyle=p.color;
ctx.beginPath(); ctx.arc(p.x,p.y,p.size*(0.5+0.5*a),0,7); ctx.fill();
}
ctx.globalAlpha=1;
for (const rg of rings){
const e=rg.life/rg.max, r=cell*(0.35+easeOut(e)*1.1);
ctx.globalAlpha=(1-e)*0.7;
ctx.strokeStyle='#ff9a62'; ctx.lineWidth=Math.max(1,(1-e)*3.5);
ctx.beginPath(); ctx.arc(rg.x,rg.y,r,0,7); ctx.stroke();
}
ctx.globalAlpha=1;
ctx.textAlign='center'; ctx.textBaseline='middle';
for (const q of pops){
const e=q.life/q.max;
ctx.globalAlpha=(1-e)*(1-e);
ctx.font=`700 ${Math.round(cell*0.62)}px ui-monospace, Consolas, monospace`;
ctx.fillStyle='#d7ffe3';
ctx.strokeStyle='rgba(10,25,16,0.8)'; ctx.lineWidth=3; ctx.lineJoin='round';
ctx.strokeText(q.text,q.x,q.y); ctx.fillText(q.text,q.x,q.y);
}
ctx.globalAlpha=1;
}
```
easeOut(e)=1-(1-e)^3.
Note pops at food location — might be under snake head; offset upward: q.y starts at fy - cell*0.5 and rises. Set at spawn: y: fy - cell*0.2, and rises.
updateFx(dt) only when mode!=='paused'. During 'over', particles continue (nice, they finish fading). ✓
Main loop:
```js
function frame(now){
requestAnimationFrame(frame);
if (!lastFrame) lastFrame=now;
const dt=Math.min(50, now-lastFrame); lastFrame=now;
if (mode==='playing'||mode==='demo'){
if (now-lastTick>1000) lastTick=now;
let guard=0;
while(now-lastTick>=tickMs && guard++<8){
lastTick+=tickMs;
step();
if (mode!=='playing'&&mode!=='demo') break;
}
}
if (mode!=='paused') updateFx(dt);
render(now);
}
requestAnimationFrame(frame);
```
Guard 8 prevents long catch-up. After break (death), lastTick remains; render uses t=1 for over. Fine.
Pause functions:
```js
function pauseGame(){ if(mode!=='playing')return; mode='paused'; pausedAt=performance.now(); ovPause.classList.add('show'); }
function resumeGame(){ if(mode!=='paused')return; lastTick += performance.now()-pausedAt; mode='playing'; ovPause.classList.remove('show'); }
```
startGame(): resetGame(false). Also hide overlays handled in resetGame.
Keydown handler details:
```js
const MODIFIERS = new Set(['ShiftLeft','ShiftRight','ControlLeft','ControlRight','AltLeft','AltRight','MetaLeft','MetaRight','CapsLock','NumLock','ContextMenu','F5','F12']);
const KEYMAP = {
ArrowUp:'up', KeyW:'up', ArrowDown:'down', KeyS:'down',
ArrowLeft:'left', KeyA:'left', ArrowRight:'right', KeyD:'right'
};
window.addEventListener('keydown', (e)=>{
if (e.ctrlKey||e.metaKey||e.altKey) return;
const c=e.code;
if (MODIFIER-ish ignore? handle inside demo branch)
if (mode==='demo' || (mode==='over'&&overFrom==='demo')){
if (!MODIFIERs && c!=='F5'... ) { e.preventDefault(); startGame(); }
return;
}
if (c==='KeyP'){ e.preventDefault(); mode==='playing'?pauseGame():resumeGame(); return; }
if (c==='Space'||c==='Enter'||c==='NumpadEnter'){
e.preventDefault();
if (mode==='over'){ startGame(); }
else if (mode==='paused'){ resumeGame(); }
return;
}
const d=KEYMAP[c];
if (d){
e.preventDefault();
if (mode==='paused') resumeGame();
if (mode==='playing') enqueue(DIRS[d]);
}
});
```
Note: pressing P during 'over' does nothing (resumeGame no-op) ✓. Space during playing: nothing (avoid accidental restart) ✓.
For demo takeover: exclude keys like F12, F5 — but with ctrl/meta checks and MODIFIERS set. Also exclude 'Tab'? Tab would blur focus — e.preventDefault stops tabbing; acceptable for a game page? Slightly aggressive but common in games. I'll exclude Tab from starting (let it behave default? Tab moves focus... in iframe fine). I'll not special-case; include everything except modifiers and F-keys: check `if (c.startsWith('F') && c.length<=3) return;`. Keep simple: ignore keys in a small set: Shift/Control/Alt/Meta/CapsLock/Tab/F-keys. Let me implement:
```js
function isIgnorable(code){
return /^(Shift|Control|Alt|Meta|CapsLock|NumLock|ScrollLock|Tab|F\d+|ContextMenu|Escape|Pause)/.test(code) || code==='Escape' || code==='Pause'...
}
```
Hmm 'KeyP' — 'Pause' is the Pause key code. My regex `^Pause` would match code 'Pause' (the Pause/Break key) but not 'KeyP'. Good. Also Escape: ignore (don't start on Escape) — fine.
So in demo branch: `if (!ignorable) { preventDefault; startGame(); } return;`
Pointer handlers:
```js
let swipe=null;
document.addEventListener('pointerdown', (e)=>{
if (mode==='demo' || (mode==='over'&&overFrom==='demo')) { startGame(); return; }
if (mode==='paused') { resumeGame(); return; }
if (mode==='playing'){ swipe={x:e.clientX,y:e.clientY,moved:false}; }
});
window.addEventListener('pointermove', e=>{
if (!swipe) return;
const dx=e.clientX-swipe.x, dy=e.clientY-swipe.y;
if (Math.hypot(dx,dy)>26){
const d = Math.abs(dx)>Math.abs(dy) ? (dx>0?'right':'left') : (dy>0?'down':'up');
enqueue(DIRS[d]); swipe={x:e.clientX,y:e.clientY};
}
});
window.addEventListener('pointerup', ()=>{ swipe=null; });
```
This gives swipe steering on mobile. Note pointerdown starting game fires even when clicking footer text — fine.
Careful: pointerdown starting the game also triggers on... no issues.
Resize:
```js
function layout(){
dpr=Math.min(2, window.devicePixelRatio||1);
const availW=Math.min(window.innerWidth-20, 1000);
const chrome = hud.offsetHeight + foot.offsetHeight + 24 + 20;
const availH = window.innerHeight - chrome;
let c=Math.floor(Math.min(availW/COLS, availH/ROWS));
c=Math.max(10,Math.min(c,32));
cell=c; W=cell*COLS; H=cell*ROWS;
canvas.style.width=W+'px'; canvas.style.height=H+'px';
canvas.width=Math.round(W*dpr); canvas.height=Math.round(H*dpr);
ctx.setTransform(dpr,0,0,dpr,0,0);
hud.style.width=W+'px'; foot.style.width=W+'px';
buildBG();
}
window.addEventListener('resize', layout);
```
Hmm the #app has padding 12 & gap 12: chrome = hudH + footH + gap*2 (24) + padding*2 (24) → +48. availH = innerHeight - hudH - footH - 48. Let me write: `const availH = window.innerHeight - hud.offsetHeight - foot.offsetHeight - 52;` safe.
Also stage needs explicit size? Stage wraps canvas: stage display inline-block/fit-content; width auto from canvas. Overlays inset 0 over stage ✓ (stage shrink-wraps canvas: make #stage{position:relative} and canvas display:block → stage size = canvas size ✓).
buildBG():
```js
function buildBG(){
bg=document.createElement('canvas');
bg.width=Math.round(W*dpr); bg.height=Math.round(H*dpr);
const b=bg.getContext('2d');
b.setTransform(dpr,0,0,dpr,0,0);
b.fillStyle='#0f1a14'; b.fillRect(0,0,W,H);
b.fillStyle='rgba(255,255,255,0.022)';
for(let y=0;y<ROWS;y++)for(let x=0;x<COLS;x++){ if((x+y)&1) b.fillRect(x*cell,y*cell,cell,cell); }
// vignette
const g=b.createRadialGradient(W/2,H/2,Math.min(W,H)*0.25,W/2,H/2,Math.max(W,H)*0.72);
g.addColorStop(0,'rgba(0,0,0,0)'); g.addColorStop(1,'rgba(0,0,0,0.30)');
b.fillStyle=g; b.fillRect(0,0,W,H);
}
```
drawImage(bg,0,0,W,H) with ctx transformed by dpr → scales bg canvas (device px W*dpr) down to W css px in dpr space = exact pixels ✓.
init:
```js
layout();
resetGame(true);
requestAnimationFrame(frame);
```
But resetGame uses performance.now for lastTick and placeFood uses foodAt — fine before rAF.
Wait — the very first paint: screenshot at ~3s: demo running with overlay. ✓
One more check: `ctx.clearRect(0,0,W,H)` uses transform — ✓.
Also `pops` textBaseline set once per drawFx — fine.
Score chip pop CSS:
```css
@keyframes pop { 0%{transform:scale(1)} 35%{transform:scale(1.22)} 100%{transform:scale(1)} }
.value.pop{ animation: pop .3s ease; display:inline-block }
```
Re-trigger via class remove/offsetWidth/add.
Record chip glow:
```css
.chip.record{ border-color:rgba(190,255,214,.6); box-shadow:0 0 16px rgba(74,222,128,.35); }
.chip.record .value{ color:#b7f7cd; }
```
Demo chip (hidden default):
```css
#demoChip{ display:none; }
body.demo #demoChip{ display:flex; }
```
Chip inner: `<span class="label">AI demo</span><span class="dot"></span>` — layout: label + dot row. Let me structure chips uniformly: `.chip{display:flex;flex-direction:column;align-items:center;gap:2px;...}`. For demo chip: label row + dot centered. dot with pulse animation.
Also add `<div class="label">` etc.
HUD brand snake svg gradient — define inside svg defs with id, fine.
Now finalize over overlay text elements: ids: ovOverTitle, finalScore, finalBest, newBestTag.
winGame():
```js
function winGame(){
food=null; mode='over'; overFrom='playing'; deathAt=performance.now(); deathDir={x:0,y:0};
if(score>best) best=score; bestVal.textContent=best;
finalScore.textContent=score; finalBest.textContent=best; newBestTag.hidden=false;
ovOverTitle.textContent='PERFECT!';
ovOver.classList.add('show');
}
```
And die() sets ovOverTitle.textContent='GAME OVER'.
placeFood sets food possibly null + winGame — but placeFood is called from onEat and resetGame. In resetGame food null would break initial render guard `if(food)`. OK.
Hmm wait — in placeFood, when no free cells, winGame called while mode is 'playing' — but onEat already unshifted head (snake fills board). Fine.
AI: note when food is null — not during play. OK.
Also the aiDir blocked-set uses body — during demo with queue empty. ✓
One more detail — `prevBody[i] || prevBody[prevBody.length-1]`: when i < prevBody.length use prevBody[i]; else last. Write:
```js
const from = i<prevBody.length ? prevBody[i] : prevBody[prevBody.length-1];
```
Also on resetGame, prevBody=copy so from==to ✓.
Font for canvas text: `ui-monospace` — canvas font parsing: use `'700 16px Consolas, monospace'`? ui-monospace in canvas font may fail parse in some browsers → fallback needed. Chrome supports ui-monospace in canvas? To be safe: `600 ${size}px Menlo, Consolas, monospace`. Fine.
Now HTML skeleton assembly. Also meta viewport. Title.
Let me also double check the requirement "Eating food adds 10 points and grows the snake by one cell" ✓ (we don't pop tail on eat). "new food never spawns on the snake" ✓ (free list). "Game over when the snake hits a wall or itself, with an overlay showing the score" ✓. "Space or Enter restarts, P pauses" ✓. "smooth movement" ✓ interpolated. "distinct head and eyes facing its direction" ✓. "particle burst when eating" ✓. "score and best score of the session" ✓ (in-memory). "Arrow keys and WASD" ✓. "Quick successive key presses both honored, no reverse" ✓ queue. "grid at least 20×20, centered" ✓ 26×22 centered. "Click or press a key to start" ✓ demo overlay.
Also "Games must start in a self-playing demo mode" ✓.
Now, will the demo AI + overlay starting immediately be visible at 3s? Yes, snake moving, eating, particles.
Let me be careful about one visual thing: the start overlay card covers the center of the board. The AI snake starts at center behind the card — visible through translucent blur around edges. Maybe place the start card slightly lower (like top 62%)? The snake roams everywhere though. Card ~200px tall on a 660px board → fine.
Alternatively make the demo board rendering slightly dimmed via overlay bg rgba(6,10,8,0.25) + card translucent — good depth.
Let me now write the aiDir more carefully with proper ordering constants:
```js
const AI_DIRS=[{x:1,y:0},{x:-1,y:0},{x:0,y:1},{x:0,y:-1}];
function aiDir(){
const head=body[0];
let bestD=null,bestS=-Infinity;
for(const d of AI_DIRS){
if(d.x===-dir.x&&d.y===-dir.y) continue;
const nx=head.x+d.x, ny=head.y+d.y;
if(nx<0||ny<0||nx>=COLS||ny>=ROWS) continue;
const eating = food && nx===food.x && ny===food.y;
const blocked=new Set();
const lim = eating?body.length:body.length-1;
for(let i=0;i<lim;i++) blocked.add(body[i].y*COLS+body[i].x);
if(blocked.has(ny*COLS+nx)) continue;
const area=flood(nx,ny,blocked);
const dist=food?Math.abs(nx-food.x)+Math.abs(ny-food.y):0;
let s= area>=body.length? 1e6+ (500-dist*8) : area*40 - dist*8;
s+=Math.random()*3;
if(s>bestS){bestS=s;bestD=d;}
}
return bestD;
}
function flood(sx,sy,blocked){
const seen=new Uint8Array(COLS*ROWS);
const stack=[sy*COLS+sx]; seen[sy*COLS+sx]=1;
let count=0;
while(stack.length){
const idx=stack.pop(); count++;
const x=idx%COLS,y=(idx/COLS)|0;
// neighbors
if(x>0){const j=idx-1; if(!seen[j]&&!blocked.has(j)){seen[j]=1;stack.push(j);} }
if(x<COLS-1){const j=idx+1; if(!seen[j]&&!blocked.has(j)){seen[j]=1;stack.push(j);} }
if(y>0){const j=idx-COLS; if(!seen[j]&&!blocked.has(j)){seen[j]=1;stack.push(j);} }
if(y<ROWS-1){const j=idx+COLS; if(!seen[j]&&!blocked.has(j)){seen[j]=1;stack.push(j);} }
}
return count;
}
```
Blocked includes body cells; note the flood counts free cells reachable — head cell itself counted (it's not blocked since blocked contains body, and next-head isn't body — we checked). OK.
Dist weighting: safe branch: 1e6 + 500 - dist*8 → among safe moves choose min dist (max s). Unsafe: area*40 - dist*8: area up to 576*40=23040 — dominates dist. So when all unsafe, biggest area wins. Good.
Edge: early game all four dirs safe with area >= len (board mostly empty) → chooses min dist → straight greedy + flood guard.
One concern: greedy + flood can still trap in late game — acceptable, demo restarts.
Now, potential subtle bug: when the AI eats and grows long quickly, the flood 'area>=body.length' with tail excluded etc. Fine.
Also demo snake and the queue: mode demo ignores queue; but if user pressed a key during demo, we startGame — queue cleared in reset. ✓
Another subtlety: startGame during demo mid-interpolation — resetGame sets lastTick=now → smooth. ✓
Now — "overFrom" init 'demo'; resetGame(demo) sets isDemoRun; die reads isDemoRun. ✓ Also `overFrom` used in keydown; set it in die only. Initial value irrelevant.
Let me also add a subtle detail: when game starts (player), maybe brief "GO" popup at snake head? Add pops.push({text:'GO!',...}) at center. Cute. Add at resetGame(false) only.
Also on player start, overlay hides with transition ✓.
Check for console errors: all element ids must exist. Careful with `document.body.classList.toggle('demo', demo)`.
Text: avoid apostrophes issues in JS strings — fine.
Potential issue: `ctx.ellipse` — supported ✓.
Particles colors array pick:
```js
const PCOLORS=['#ff6b5e','#ffb35c','#ffe066','#8ce99a','#ff8fa3'];
```
burst:
```js
function burst(x,y){
const s=cell/28;
const n=16;
for(let i=0;i<n;i++){
const a=Math.random()*Math.PI*2;
const sp=(0.05+Math.random()*0.17)*s;
particles.push({
x,y,
vx:Math.cos(a)*sp, vy:Math.sin(a)*sp-0.03*s,
life:0, max:380+Math.random()*320,
size:cell*(0.05+Math.random()*0.075),
color:PCOLORS[(Math.random()*PCOLORS.length)|0]
});
}
}
```
update: gravity g=0.00025*s? earlier computed reasonable: adds 0.25*s px/ms per 1000ms → over 400ms vy+=0.1*s px/ms — ok subtle. Actually let me add slightly more: 0.00035.
drag: `const f=Math.exp(-dt/420)`.
Popup rise 0.03*s px/ms over 800ms = 24px*s — plus starting offset. Good.
Let me also double-check the tick while-loop with mode changes: after step() sets mode='over', break ✓. After demo auto-restart? That's in timeout, not in loop. ✓
pausedAt during pause: render uses it. ✓ But note during pause we skip updateFx — pops/rings freeze — good.
Now think about `render` when mode==='over' and overFrom==='demo': overlay ovStart is hidden (since resetGame only toggles on reset). When demo dies: ovStart currently 'show'; we need it hidden during the death beat then reshown by resetGame. Add in die(): `ovStart.classList.remove('show')` when demo death; resetGame re-adds. For playing death it's already hidden. Also hide ovPause on die (can't die while paused). ✓
Also when player starts from over-state: resetGame(false) hides ovOver ✓ (classList.remove).
HUD during demo: score shows AI score; best stays from player sessions. Demo chip visible. ✓
Now CSS for `.card` sizes etc. Let me write everything concretely.
Additional aesthetic: board border glow accent line at top? The canvas box-shadow covers.
Let me write final HTML now. I'll write it carefully and completely.
```html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Snake</title>
<style>
:root{
--bg:#0a0f0c;
--panel:#0f1a14;
--text:#e7f4ea;
--muted:#8fa898;
--accent:#4ade80;
--accent-soft:rgba(134,239,172,.16);
}
*{box-sizing:border-box;margin:0;padding:0}
html,body{height:100%}
body{
height:100dvh; overflow:hidden;
background:var(--bg);
background-image:radial-gradient(1100px 640px at 50% 8%, rgba(74,222,128,.08), transparent 62%);
color:var(--text);
font-family:ui-sans-serif,system-ui,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;
display:flex;align-items:center;justify-content:center;
-webkit-user-select:none;user-select:none;
}
#app{display:flex;flex-direction:column;align-items:center;gap:12px;padding:12px}
#hud{display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap}
.brand{display:flex;align-items:center;gap:10px}
h1{font-size:clamp(17px,2.3vw,23px);font-weight:800;letter-spacing:.38em;margin-right:-.38em;
background:linear-gradient(92deg,#c4f7cf,#4ade80 55%,#2f9e63);
-webkit-background-clip:text;background-clip:text;color:transparent}
.stats{display:flex;gap:8px}
.chip{display:flex;flex-direction:column;align-items:center;gap:1px;min-width:72px;
padding:5px 12px;border-radius:11px;background:rgba(255,255,255,.035);
border:1px solid rgba(255,255,255,.09);transition:border-color .3s, box-shadow .3s}
.chip .label{font-size:9px;letter-spacing:.18em;text-transform:uppercase;color:var(--muted)}
.chip .value{font-family:ui-monospace,"SF Mono",Menlo,Consolas,monospace;font-size:16px;font-weight:700;line-height:1.15}
.chip.record{border-color:rgba(183,247,205,.55);box-shadow:0 0 18px rgba(74,222,128,.28)}
.chip.record .value{color:#b7f7cd}
.value.pop{animation:pop .32s ease}
@keyframes pop{0%{transform:scale(1)}40%{transform:scale(1.25)}100%{transform:scale(1)}}
#demoChip{display:none;flex-direction:row;align-items:center;gap:7px;padding:5px 12px}
body.demo #demoChip{display:flex}
.dot{width:7px;height:7px;border-radius:50%;background:#fbbf24;box-shadow:0 0 10px rgba(251,191,36,.9);animation:blink 1.1s ease-in-out infinite}
@keyframes blink{50%{opacity:.25}}
#stage{position:relative;line-height:0}
canvas{display:block;border-radius:14px;background:var(--panel);
box-shadow:0 0 0 1px rgba(134,239,172,.18),0 30px 70px rgba(0,0,0,.55),0 0 90px rgba(74,222,128,.06);
touch-action:none}
...
```
Hmm `line-height:0` on stage to avoid inline gap. Canvas display:block already avoids gap. OK remove.
Overlays:
```css
.overlay{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;
background:rgba(5,9,7,.28);border-radius:14px;opacity:0;visibility:hidden;
transition:opacity .35s ease, visibility .35s;pointer-events:none}
.overlay.show{opacity:1;visibility:visible}
.card{background:rgba(10,17,13,.72);border:1px solid rgba(134,239,172,.2);border-radius:18px;
padding:20px 30px;text-align:center;backdrop-filter:blur(9px);-webkit-backdrop-filter:blur(9px);
box-shadow:0 24px 60px rgba(0,0,0,.5);transform:translateY(8px) scale(.97);transition:transform .35s cubic-bezier(.2,.9,.25,1.2)}
.overlay.show .card{transform:none}
.ov-kicker{font-size:10px;letter-spacing:.34em;text-transform:uppercase;color:var(--muted);margin-bottom:6px}
.ov-title{font-size:clamp(26px,4vw,38px);font-weight:800;letter-spacing:.3em;margin-right:-.3em;
background:linear-gradient(92deg,#c4f7cf,#4ade80 60%,#2f9e63);-webkit-background-clip:text;background-clip:text;color:transparent}
.ov-title.sm{font-size:clamp(20px,3vw,26px)}
.ov-title.danger{background:linear-gradient(92deg,#ffc9c9,#f87171 60%,#b91c1c);-webkit-background-clip:text;background-clip:text;color:transparent}
.ov-sub{margin-top:8px;font-size:13px;color:var(--muted)}
.cta{margin-top:16px;display:inline-flex;align-items:center;gap:8px;padding:9px 18px;border-radius:999px;
font-size:13px;font-weight:600;color:#d9ffe6;background:rgba(74,222,128,.12);
border:1px solid rgba(134,239,172,.4);animation:ctaPulse 2s ease-in-out infinite}
@keyframes ctaPulse{50%{box-shadow:0 0 22px rgba(74,222,128,.35);border-color:rgba(183,247,205,.75)}}
.ov-keys{margin-top:12px;font-size:11px;color:var(--muted)}
.final{margin-top:14px}
.fl-label{display:block;font-size:10px;letter-spacing:.26em;text-transform:uppercase;color:var(--muted)}
.fl-value{font-family:ui-monospace,Menlo,Consolas,monospace;font-size:40px;font-weight:800;color:#d9ffe6;line-height:1.1}
.newbest{margin-top:6px;font-size:11px;font-weight:700;letter-spacing:.12em;color:#fbbf24;text-transform:uppercase}
kbd{display:inline-block;min-width:20px;padding:2px 6px;border-radius:6px;background:rgba(255,255,255,.07);
border:1px solid rgba(255,255,255,.14);border-bottom-width:2px;font-family:ui-monospace,Menlo,Consolas,monospace;
font-size:11px;color:#cfe8d6}
#foot{display:flex;align-items:center;justify-content:center;gap:16px;flex-wrap:wrap;font-size:11.5px;color:var(--muted)}
.hint{display:inline-flex;align-items:center;gap:5px}
@media (max-width:520px){
#hud{justify-content:center}
#foot{gap:10px}
}
```
HTML body:
```html
<div id="app">
<header id="hud">
<div class="brand">
<svg width="24" height="24" viewBox="0 0 24 24" aria-hidden="true">
<defs><linearGradient id="lg" x1="0" y1="1" x2="1" y2="0">
<stop offset="0" stop-color="#2f9e63"/><stop offset="1" stop-color="#a7f3c9"/>
</linearGradient></defs>
<path d="M4.5 17.5c0-3.2 3.4-3.6 6.2-3.9 3-.3 6.3-.9 6.3-4.2 0-2.6-2.2-3.9-4.6-3.9"
fill="none" stroke="url(#lg)" stroke-width="3.6" stroke-linecap="round"/>
<circle cx="17.2" cy="17.2" r="3.1" fill="url(#lg)"/>
<circle cx="18.3" cy="16.3" r=".7" fill="#0a0f0c"/>
</svg>
<h1>SNAKE</h1>
</div>
<div class="stats">
<div class="chip" id="demoChip"><span class="label">AI demo</span><span class="dot"></span></div>
<div class="chip" id="scoreChip"><span class="label">Score</span><span class="value" id="scoreVal">0</span></div>
<div class="chip" id="bestChip"><span class="label">Best</span><span class="value" id="bestVal">0</span></div>
</div>
</header>
<div id="stage">
<canvas id="game"></canvas>
<div class="overlay show" id="ovStart">
<div class="card">
<div class="ov-kicker">Classic arcade</div>
<div class="ov-title">SNAKE</div>
<div class="ov-sub">The snake is playing itself — hop in anytime.</div>
<div class="cta">Click or press a key to start</div>
<div class="ov-keys"><kbd>↑</kbd><kbd>↓</kbd><kbd>←</kbd><kbd>→</kbd> or <kbd>W</kbd><kbd>A</kbd><kbd>S</kbd><kbd>D</kbd> to steer</div>
</div>
</div>
<div class="overlay" id="ovPause">
<div class="card">
<div class="ov-title sm">PAUSED</div>
<div class="ov-sub">Press <kbd>P</kbd> or click to resume</div>
</div>
</div>
<div class="overlay" id="ovOver">
<div class="card">
<div class="ov-title sm danger" id="ovOverTitle">GAME OVER</div>
<div class="final">
<span class="fl-label">Score</span>
<span class="fl-value" id="finalScore">0</span>
</div>
<div class="newbest" id="newBestTag" hidden>★ New session best ★</div>
<div class="ov-sub">Session best: <b id="finalBest">0</b></div>
<div class="cta">Press <kbd>Space</kbd> or <kbd>Enter</kbd> to restart</div>
</div>
</div>
</div>
<footer id="foot">
<span class="hint"><kbd>W</kbd><kbd>A</kbd><kbd>S</kbd><kbd>D</kbd> / <kbd>↑</kbd><kbd>↓</kbd><kbd>←</kbd><kbd>→</kbd> move</span>
<span class="hint"><kbd>P</kbd> pause</span>
<span class="hint"><kbd>Space</kbd> restart</span>
</footer>
</div>
<script> ... </script>
```
The ovStart has class "show" initially in markup — but resetGame(true) will also set it. Keep markup with show for first paint (avoids flash if JS ordering hiccup — fine either way).
Wait: newBestTag uses `hidden` attribute — CSS display of .newbest overridden by hidden? `hidden` sets display:none via UA stylesheet, but my .newbest sets display default block — hidden attribute still works unless my CSS sets display explicitly. I don't set display on .newbest → hidden works. ✓ But I toggle `newBestTag.hidden = !(isNew && score>0)` ✓.
Also the CTA containing kbd with Space — cta has border pulse; fine.
Now think about layout() being called before fonts/layout settle: hud.offsetHeight measured at first call — DOM ready since script at end of body ✓.
One catch: `#hud` width set in px by JS; initial CSS none — before JS runs, header is full width; fine.
Also: `overflow:hidden` on body prevents scrollbars ✓.
Check: at 1280×800: hud height ~ 40px (chips 40) but brand svg 24 — ~44. foot ~24. availH = 800-44-24-52=680 → cell=min(1000/26=38.5→ but capped 32, 680/22=30.9)=30 → board 780×660. Total height: 12pad+44+12gap+660+12gap+24+12pad = 776 ✓ <800. Width 780<1280 centered ✓. cell capped at 32 anyway; got 30.
At 375×667 (mobile): availW=355, chrome= hud (maybe wraps to ~90 due to narrow? stats 3 chips ~ 260px wide + brand ~120 → 380 > 355 → wraps → hud height ~ 92). foot wraps too maybe ~72? availH=667-92-72-52=451 → cell=min(355/26=13.6, 451/22=20.5)=13 → board 338×286. Height total 12+92+12+286+12+72+12=498 <667 ✓. Width fine.
At 375 width, board 338 wide with 26 cols → cell 13 — snake thin but playable. Good.
What about very short heights (landscape phone 667×375): availH = 375 - 44 - 24 - 52 = 255 → cell=min(647/26, 255/22=11.6)=11 → board 286×242. OK.
cell minimum clamp 10. Fine.
Now — screenshot at 3s: board visible, demo running behind translucent start card ✓.
Let me reconsider the initial snake position with cell interpolation: body head at x=12..9? I wrote `for i<4: body.push({x:12-i, y:cy})` → head (12,11), tail (9,11). Moving right toward food. Good, food random.
One more polish: draw subtle rounded inner border on board? bg has vignette; enough.
Now — during 'over' (player), the death shake random each frame — ok.
Also I should double check the "record" class removal at die() and reset ✓.
Also `scoreChip` reference. `popEl(scoreVal)` implement:
```js
function popEl(el){ el.classList.remove('pop'); void el.offsetWidth; el.classList.add('pop'); }
```
Now potential problem: In render, when body length 4 and prevBody length 4 — fine. Also drawSnake when body.length===1? Not possible (min 4). pathThrough with ≥2 pts ✓.
Also, careful: after eating, body length +1; prevBody length stays old → interpolation index n-1 (new tail) from = prevBody[last] = old tail = same as body[last]?? Wait: on eat, body = [nh, ...old]; body[oldLen] = old[oldLen-1] (old tail). prevBody = old array (length oldLen). For i=oldLen: from = prevBody[oldLen-1] = old tail = body[oldLen] → static ✓. For i=oldLen-1: from=prevBody[oldLen-1]=old tail; to=body[oldLen-1]=old[oldLen-2] — moves forward ✓.
Now, another consideration: the demo overlay says "The snake is playing itself" — also mention AI: the demo chip says "AI demo". Good.
Double-check keydown 'Enter' code 'Enter' ✓, numpad enter 'NumpadEnter' include ✓.
Also prevent arrow-key page scroll — we preventDefault on arrows ✓ (body overflow hidden anyway).
Edge: When user holds a key, auto-repeat keydown fires repeatedly — enqueue ignores duplicates ✓.
Now write drawHead fully with variables and check math once more:
r = cell*0.44. body halfwidth max = cell*0.35+? body width near head cell*0.70 → radius .35; rim .82/2=.41. Head radius .44 ≥ .41 covers rim → head slightly larger ✓.
Eyes: fw=cell*0.15 (forward offset), sd=cell*0.185 (side offset), eye radius cell*0.115, pupil cell*0.055 offset dir*cell*0.04.
For cell 30: r=13.2; eye r 3.45; side 5.55 — eyes at ±5.55 from center, forward 4.5 — inside head (13.2) ✓. Looks cute.
Glint: white dot r cell*0.02 at pupil pos offset (-1,-1)*cell*0.02.
Blink handling: `const blink = (now % 3600) > 3480;` blink true for last 120ms of each 3.6s: scale ry (perp to dir)... as discussed use rotation:
```js
const rot = Math.atan2(dy,dx);
ctx.ellipse(ex,ey, rEye*(blink?0.25:1)...
```
wait which axis is which under rotation: ellipse(x, y, radiusX, radiusY, rotation) — radiusX along the rotated x-axis (direction of rotation angle). With rot=atan2(dy,dx), the x-axis aligns with movement. Squash along movement for blink: radiusX small. Hmm, blinking squashes the eye along the lid line — lids close perpendicular to view... whatever looks fine: squashing along movement axis reads as a blink for a top-down snake. Do radiusX = eyeR*(blink?0.2:1), radiusY=eyeR.
Actually hmm, thinking again: for a snake viewed from above, "blink" is stylized; squashing along direction works.
Tongue: skip during 'over' and 'paused'? During pause now advances → tongue animates behind blur. Fine, skip only 'over'.
Also don't draw tongue when blink? irrelevant.
Let me also add subtle body "breathing" width pulse? skip — enough polish.
Now let me also handle: `document.hidden` big gap → the `now-lastTick>1000` reset handles ✓.
Another potential console error: `newBestTag` etc. — define all consts from getElementById.
Also careful — script uses `body` as a variable name for the snake while `document.body` exists; inside IIFE, my local `body` shadows nothing problematic since I use document.body explicitly via classList: `document.body.classList` — I wrote document.body — fine (body variable is snake cells; document.body is explicit). ✓
Wait, careful — I named snake array `body`. And `document.body.classList.toggle('demo', demo)` — uses document.body, unaffected by local var. ✓
Let me also verify enqueue:
```js
function enqueue(d){
if(!d) return;
const ref = queue.length ? queue[queue.length-1] : dir;
if (d.x===ref.x && d.y===ref.y) return;
if (d.x===-ref.x && d.y===-ref.y) return;
if (queue.length>=3) queue.shift(); // drop oldest to keep latest inputs?
queue.push(d);
}
```
Hmm dropping oldest could discard a useful turn; better ignore new if full? "Quick successive key presses must both be honored" — pressing 4 dirs within one tick is extreme; cap 3 with push after shift keeps latest 3 — better honors latest intent. I'll do shift+push. But shifting then pushing reverse-unsafe? The queue remains internally consistent (each consecutive pair differs & non-opposite since validated against tail at insertion... after shift, first element still validated against dir or prior? First element was validated against the element before it at insert time — which may have been shifted out... edge-case paranoia; the only risk is element[0] being reverse of dir: element[0] was validated against (queue.tail at its insert time or dir). If queue was [A,B], dir; insert C validated vs B. Shift removes A. [B,C]. B validated vs A, and A vs dir — could B be reverse of dir? If dir=Right: A=Up (ok), B=Left? B validated vs A: Left vs Up not reverse → allowed. So queue [Up,Left] with dir Right: tick1 applies Up... then Left. Left is reverse of Right but by then dir is Up — safe. But if A gets shifted out (queue overflow), B=Left could become first and applied while dir still Right → reversal! Scenario: dir=Right, quickly press Up, Left, Down, Right within one tick:
- Up validated vs Right ✓ queue=[Up]
- Left vs Up ✓ queue=[Up,Left]
- Down vs Left ✓ queue=[Up,Left,Down]
- Right vs Down ✓ queue full → shift → [Left,Down], push Right → [Left,Down,Right]
Now tick1: dir=Left while snake moving Right → REVERSAL onto itself! Bug.
Fix: after shift, re-validate queue[0] against dir? Simpler: when queue full, ignore the new press (return). Then max 3 buffered; 4 presses in one tick (~130ms) is humanly near-impossible (that's >23 keypresses/sec... actually 4 presses in 130ms possible with fast alternates? Rare). Safest: don't shift; just cap and ignore. I'll ignore when length>=3. Reversal impossible since validation chain intact from dir. ✓
Actually also validate queue[0] against dir always? Chain: queue[0] validated against dir at insert (if queue was empty then) or against queue element before it... if queue was [X] and X validated vs dir; later inserts append. Elements never removed except shift() at tick (one at a time, applied to dir immediately). So chain always intact ✓.
At tick: `dir = queue.shift()` — dir updated then next element validated vs old dir at insert = consistent ✓.
OK. Also note: at reset, queue=[] and dir=Right ✓.
Another thing: pressing direction keys during 'over' (playing) — the keydown branch: KEYMAP only enqueues when mode==='playing'. During 'over', arrow presses ignored; Space/Enter restart ✓. Should arrows also restart? Spec: Space or Enter restarts. Keep spec.
But hmm — during 'over', before the overlay shows (650ms), user might press arrow expecting restart... spec-compliant: no. OK.
What about pressing key during 'over' but overFrom==='demo' — handled: any key starts. But wait — during demo 'over' window, pressing 'P' would startGame (since branch checks mode==='over'&&overFrom==='demo' first). Fine — any key starts per the demo rule.
Now, subtle: in keydown, the demo-start branch triggers for ANY key including F12/Escape? I filter ignorable set. `Escape` — include in ignore (don't start on Esc). Implement:
```js
const IGNORE=/^(Shift|Control|Alt|Meta|CapsLock|NumLock|ScrollLock|Escape|Tab|ContextMenu|F\d)/;
```
'F5' matches F\d ✓, 'KeyF'? starts with "KeyF" not "F" — regex anchored ^F\d requires F followed by digit: 'F5' ✓, 'KeyF' no ✓. 'Fn'? whatever.
In demo branch: `if(!IGNORE.test(c)){ e.preventDefault(); startGame(); } return;`
For playing branch arrows/WASD etc. — preventDefault fine.
Now also make sure clicking to start doesn't get immediately followed by the same click causing swipe weirdness: pointerdown in demo → startGame, return; swipe stays null ✓.
Also pointermove listener with no pointer capture: fine.
Also `pointercancel` → swipe=null.
Alright, also consider: user clicks canvas during 'playing' → sets swipe; drag → steer. Nice bonus.
Let me now also compute `hud.offsetHeight` before layout is affected by width... The width is set AFTER measuring; chips don't change height with width much (min-width fixed, no wrap inside chips). Header wraps at narrow widths → height changes; then availH uses stale height → board maybe slightly too tall/short by one row — negligible; but to be safer: after setting widths, call layout again once on next frame? That could loop infinitely (layout → rAF layout → stops when stable). I'll do: `let rafLayout=0; function relayout(){...}` — meh. Simple approach: measure with `#app` at current state; the error is ≤ ~44px. To be safe, subtract extra margin: I already subtract 52 total padding+gaps (actual 48). Fine.
Actually improve: set hud width to board width, THEN re-measure and if height differs by >2 recompute once:
```js
function layout(){
... compute cell with current hudH/footH ...
apply sizes;
const nh = hud.offsetHeight + foot.offsetHeight;
if (Math.abs(nh - preH) > 2 && !retry) { retry=true; layout(); } else retry=false;
}
```
Overkill — I'll just run the sizing computation twice in the same function: measure, apply, measure again, apply. Let me write:
```js
function layout(){
dpr = ...;
for (let pass=0; pass<2; pass++){
const chrome = hud.offsetHeight + foot.offsetHeight + 48;
const availW = Math.min(document.documentElement.clientWidth - 24, 1000);
const availH = window.innerHeight - chrome;
cell = Math.max(10, Math.min(32, Math.floor(Math.min(availW/COLS, availH/ROWS))));
W=cell*COLS; H=cell*ROWS;
hud.style.width = W+'px';
foot.style.width = W+'px';
}
canvas.style.width=W+'px'; canvas.style.height=H+'px';
canvas.width=Math.round(W*dpr); canvas.height=Math.round(H*dpr);
ctx.setTransform(dpr,0,0,dpr,0,0);
buildBG();
}
```
Two passes: first pass sets widths (hud may rewrap), second measures updated heights. Good enough.
window.innerWidth vs documentElement.clientWidth — use `document.documentElement.clientWidth` (excludes scrollbar; overflow hidden anyway).
Note: changing cell mid-game changes pixel coords of food/particles — they're stored in grid coords for food (recompute in render from cell ✓) but particles in px — after resize they'd be offset; acceptable (resize is rare; also could clear particles on resize — do particles.length=0 on resize if cell changed. I'll clear fx on cell change.)
Also lastTick continuity fine.
Also on resize, canvas.width reset clears canvas — next frame redraws ✓.
Now finalize the winGame overlay — also should set newBestTag properly & finalBest. And guard demo: winGame can't happen in demo realistically but if it does: handle overFrom demo: just auto-restart? Add: if isDemoRun → schedule demo restart like die. Let me fold win into die-like path: simplest — winGame() { if(isDemoRun){ scheduleRestart(); mode='over'; overFrom='demo'; return; } ... }. Given snake must fill 572 cells in demo — will happen maybe never. Keep small.
Actually let me simplify: in placeFood, if no free cells → call `winGame()`; winGame sets mode='over', overFrom = isDemoRun?'demo':'playing'; if demo → schedule demoTimer restart; else show overlay with title "PERFECT!". deathDir {0,0} so bump none; shake none (deathAt used for bump only with dir 0 → sin*0 → no motion — but flash: drawSnake checks mode over → flash red 300ms — fine, looks celebratory... eh, red flash on win is odd but ultra rare. Fine.)
Hmm wait, on win the snake filled the board — drawing full board snake. OK.
Let me now also double check step() ordering with food null (win): after win, mode='over' — but step already unshifted and called onEat→placeFood→winGame→mode='over'. Then back in step: after onEat returns, nothing else. ✓ But careful: onEat also did burst etc. before placeFood — food coords valid at that time ✓.
In onEat I use food.x/food.y BEFORE placeFood — order: read fx,fy → burst, ring, pop → placeFood() → then score updates... I wrote score first. Any order fine as long as fx,fy read before placeFood. ✓
One more: `tickMs` recalc uses isDemoRun base. In onEat: `const base = isDemoRun?118:BASE_TICK; tickMs=Math.max(MIN_TICK, base - eaten*TICK_STEP);` For demo: 118-2.4*eaten → hits 68 at eaten=21 (210 pts). OK.
Hmm — demo AI at high speed with flood fill each tick: 3 flood fills × ~572 cells × per tick 68ms — trivial.
Now, is there a risk the demo AI dies too early and looks bad? The greedy+flood AI is decent. Also demo restarts on death — judges see continuous action. Good.
Let me also double check drawSnake stroke of rim: single path with shadowBlur — shadow renders per stroke; fine.
globalAlpha management: drawFx resets to 1 at end ✓. drawFood uses save/restore ✓.
Alright — also the "record" chip glow when surpassing best — best starts 0 so first food triggers record glow. That means the score chip glows from the very first food... which is fine ("you're setting the best"). Hmm, it'd always glow in a fresh session (any score>0 beats best=0). Maybe initialize best display 0 and only glow when score>best where best>0? Then first game no glow until second game. I think glow from first food is okay visually (it signals "session best so far"). But then chip stays glowing the whole first game — meh. Alternative: update best LIVE (best=score as you pass it), and glow only when incrementing beyond previous best... then every food after first triggers glow pop — that's actually nice: chip.record pulses each food when you're beating session best. Simplest: in onEat: if(!isDemoRun && score>best){ best=score; bestVal.textContent=best; popEl(bestVal); scoreChip.classList.add('record'); }. Then best updates live — "Best score of the session" displayed ✓ updates live. At death, best already updated; finalBest shows it ✓. And newBestTag: track `startBest` at resetGame: startBest=best; at death isNew = score>0 && score===best && score>startBest... hmm if best updated live, at death score===best iff last run set records. Let me store runBestExceeded = score>startBest at die. Implement:
- resetGame(false): startBest = best.
- onEat live-updates best when exceeded.
- die(): overFrom playing: `const newRecord = score>startBest;` newBestTag.hidden = !newRecord.
And remove record-class at reset & death ✓.
This also means the over overlay "Session best: X" equals final score when newRecord — fine.
Demo runs never touch best ✓ (spec: session best — I interpret as player's; demo shows its own score in Score chip labeled with AI demo chip).
Hmm, wait: during demo, Score chip shows AI score and Best shows player best (initially 0). Reasonable.
Now write the final code. Also `let startBest=0`.
Also popEl for bestVal on record ✓ nice.
Let me write out everything and mentally run through it once more for errors.
Also: `ctx.roundRect`? not used.
Full script:
```js
(() => {
'use strict';
const COLS=26, ROWS=22;
const BASE_TICK=132, MIN_TICK=68, TICK_STEP=2.4;
const DIRS={up:{x:0,y:-1},down:{x:0,y:1},left:{x:-1,y:0},right:{x:1,y:0}};
const AI_DIRS=[DIRS.right,DIRS.left,DIRS.down,DIRS.up];
const KEYMAP={ArrowUp:'up',KeyW:'up',ArrowDown:'down',KeyS:'down',ArrowLeft:'left',KeyA:'left',ArrowRight:'right',KeyD:'right'};
const IGNORE_KEY=/^(Shift|Control|Alt|Meta|CapsLock|NumLock|ScrollLock|Escape|Tab|ContextMenu|F\d)/;
const PCOLORS=['#ff6b5e','#ffb35c','#ffe066','#8ce99a','#ff8fa3'];
const canvas=document.getElementById('game');
const ctx=canvas.getContext('2d');
const stage=document.getElementById('stage');
const hud=document.getElementById('hud');
const foot=document.getElementById('foot');
const scoreVal=document.getElementById('scoreVal');
const bestVal=document.getElementById('bestVal');
const scoreChip=document.getElementById('scoreChip');
const ovStart=document.getElementById('ovStart');
const ovPause=document.getElementById('ovPause');
const ovOver=document.getElementById('ovOver');
const ovOverTitle=document.getElementById('ovOverTitle');
const finalScore=document.getElementById('finalScore');
const finalBest=document.getElementById('finalBest');
const newBestTag=document.getElementById('newBestTag');
let cell=24,W=0,H=0,dpr=1,bg=null;
let body=[],prevBody=[],dir=DIRS.right,queue=[];
let food=null,foodAt=0;
let mode='demo',isDemoRun=true,overFrom='demo';
let score=0,eaten=0,best=0,startBest=0,tickMs=118;
let lastTick=0,pausedAt=0,deathAt=0,deathDir={x:1,y:0};
let particles=[],rings=[],pops=[];
let demoTimer=0,overTimer=0,lastFrame=0;
let swipe=null;
```
functions...
Note `pops` name conflicts none.
I need `performance.now()` alias `now()`.
Write render etc. Then events, init.
One more check — `bestVal.textContent` initial '0' in markup ✓.
resetGame:
```js
function resetGame(demo){
isDemoRun=demo;
clearTimeout(demoTimer); clearTimeout(overTimer); demoTimer=0; overTimer=0;
queue.length=0; particles.length=0; rings.length=0; pops.length=0;
const cy=ROWS>>1;
body=[];
for(let i=0;i<4;i++) body.push({x:12-i,y:cy});
prevBody=body.map(c=>({x:c.x,y:c.y}));
dir=DIRS.right;
score=0; eaten=0; startBest=best;
tickMs=demo?118:BASE_TICK;
scoreVal.textContent='0';
scoreChip.classList.remove('record');
document.body.classList.toggle('demo',demo);
ovStart.classList.toggle('show',demo);
ovPause.classList.remove('show');
ovOver.classList.remove('show');
placeFood();
lastTick=now();
mode=demo?'demo':'playing';
if(!demo){
pops.push({x:(body[0].x+0.5)*cell,y:(body[0].y-0.4)*cell,life:0,max:700,text:'GO!'});
}
}
```
Wait `pops` for 'GO!' — y above head. cell known ✓.
`now()` — define `const now=()=>performance.now();` careful shadowing with render param `now` — inside render I use parameter named now; the helper called now() would be shadowed... In functions where I define parameter `now`, calling now() would break (now is a number). I'll just call performance.now() explicitly everywhere. Remove helper.
die():
```js
function die(){
mode='over'; overFrom=isDemoRun?'demo':'playing';
deathAt=performance.now(); deathDir={x:dir.x,y:dir.y};
queue.length=0;
if(overFrom==='demo'){
ovStart.classList.remove('show');
demoTimer=setTimeout(()=>{demoTimer=0; if(mode==='over') resetGame(true);},1600);
}else{
const newRecord=score>startBest;
scoreChip.classList.remove('record');
ovOverTitle.textContent='GAME OVER';
overTimer=setTimeout(()=>{
overTimer=0;
if(mode!=='over'||overFrom!=='playing') return;
finalScore.textContent=score;
finalBest.textContent=best;
newBestTag.hidden=!newRecord;
ovOver.classList.add('show');
},650);
}
}
```
Note: best already updated live in onEat. ✓
pause/resume as before.
startGame():
```js
function startGame(){ resetGame(false); }
```
onEat:
```js
function onEat(){
const fx=(food.x+0.5)*cell, fy=(food.y+0.5)*cell;
score+=10; eaten++;
scoreVal.textContent=score; popEl(scoreVal);
if(!isDemoRun && score>best){
best=score; bestVal.textContent=best; popEl(bestVal);
scoreChip.classList.add('record');
}
const base=isDemoRun?118:BASE_TICK;
tickMs=Math.max(MIN_TICK,base-eaten*TICK_STEP);
burst(fx,fy);
rings.push({x:fx,y:fy,life:0,max:420});
pops.push({x:fx,y:fy-cell*0.2,life:0,max:800,text:'+10'});
placeFood();
}
```
step, placeFood, winGame:
```js
function placeFood(){
const occ=new Set(body.map(c=>c.y*COLS+c.x));
const free=[];
for(let y=0;y<ROWS;y++)for(let x=0;x<COLS;x++){const k=y*COLS+x; if(!occ.has(k)) free.push({x,y});}
if(!free.length){food=null; winGame(); return;}
food=free[(Math.random()*free.length)|0];
foodAt=performance.now();
}
function winGame(){
mode='over'; overFrom=isDemoRun?'demo':'playing';
deathAt=performance.now(); deathDir={x:0,y:0};
if(overFrom==='demo'){
demoTimer=setTimeout(()=>{demoTimer=0; if(mode==='over') resetGame(true);},1500);
}else{
finalScore.textContent=score; finalBest.textContent=best; newBestTag.hidden=!(score>startBest);
ovOverTitle.textContent='PERFECT!';
ovOver.classList.add('show');
}
}
```
Note winGame called from placeFood called from resetGame — during resetGame mode is still previous... resetGame calls placeFood before setting mode! If board empty free is huge — no win. fine.
But careful: resetGame → placeFood → winGame can't trigger (fresh board). ✓
step():
```js
function step(){
if(mode==='demo'){const d=aiDir(); if(d) dir=d;}
else if(queue.length) dir=queue.shift();
prevBody=body.map(c=>({x:c.x,y:c.y}));
const nh={x:body[0].x+dir.x,y:body[0].y+dir.y};
if(nh.x<0||nh.y<0||nh.x>=COLS||nh.y>=ROWS){die(); return;}
const eating=food&&nh.x===food.x&&nh.y===food.y;
const lim=eating?body.length:body.length-1;
for(let i=0;i<lim;i++){if(body[i].x===nh.x&&body[i].y===nh.y){die();return;}}
body.unshift(nh);
if(eating) onEat(); else body.pop();
}
```
Rendering functions... let me write them fully.
```js
function easeOutBack(x){const c1=1.70158,c3=c1+1;return 1+c3*Math.pow(x-1,3)+c1*Math.pow(x-1,2);}
function easeOut(x){return 1-Math.pow(1-x,3);}
function popEl(el){el.classList.remove('pop');void el.offsetWidth;el.classList.add('pop');}
function burst(x,y){
const s=cell/28;
for(let i=0;i<16;i++){
const a=Math.random()*Math.PI*2;
const sp=(0.05+Math.random()*0.17)*s;
particles.push({x,y,vx:Math.cos(a)*sp,vy:Math.sin(a)*sp-0.03*s,life:0,
max:380+Math.random()*320,size:cell*(0.05+Math.random()*0.075),
color:PCOLORS[(Math.random()*PCOLORS.length)|0]});
}
}
function updateFx(dt){
const s=cell/28;
for(let i=particles.length-1;i>=0;i--){
const p=particles[i]; p.life+=dt;
if(p.life>=p.max){particles.splice(i,1);continue;}
const f=Math.exp(-dt/420);
p.vx*=f; p.vy=p.vy*f+0.00035*s*dt;
p.x+=p.vx*dt; p.y+=p.vy*dt;
}
for(let i=rings.length-1;i>=0;i--){rings[i].life+=dt; if(rings[i].life>=rings[i].max) rings.splice(i,1);}
for(let i=pops.length-1;i>=0;i--){const q=pops[i]; q.life+=dt; q.y-=dt*0.028*s; if(q.life>=q.max) pops.splice(i,1);}
}
```
drawFood:
```js
function drawFood(t){
const grow=Math.min(1,(t-foodAt)/280);
const gs=grow<1?easeOutBack(grow):1;
const fx=(food.x+0.5)*cell, fy=(food.y+0.5)*cell;
const r=cell*0.34*gs*(1+Math.sin(t/300)*0.05);
if(r<=0) return;
ctx.save();
ctx.shadowColor='rgba(255,92,92,0.75)';
ctx.shadowBlur=cell*0.8;
ctx.fillStyle='#f4563f';
ctx.beginPath(); ctx.arc(fx,fy,r,0,6.2832); ctx.fill();
ctx.restore();
const g=ctx.createRadialGradient(fx-r*0.4,fy-r*0.45,r*0.12,fx,fy,r*1.05);
g.addColorStop(0,'#ffb9a8'); g.addColorStop(0.5,'#f4563f'); g.addColorStop(1,'#b52a2a');
ctx.fillStyle=g;
ctx.beginPath(); ctx.arc(fx,fy,r,0,6.2832); ctx.fill();
ctx.fillStyle='rgba(255,255,255,0.55)';
ctx.beginPath(); ctx.ellipse(fx-r*0.35,fy-r*0.42,r*0.22,r*0.13,-0.6,0,6.2832); ctx.fill();
ctx.strokeStyle='#8a5a2b'; ctx.lineWidth=Math.max(1.3,cell*0.055); ctx.lineCap='round';
ctx.beginPath(); ctx.moveTo(fx,fy-r*0.8); ctx.quadraticCurveTo(fx+r*0.1,fy-r*1.15,fx+r*0.32,fy-r*1.22); ctx.stroke();
ctx.fillStyle='#5ec98f';
ctx.beginPath(); ctx.ellipse(fx+r*0.6,fy-r*1.08,r*0.34,r*0.17,-0.5,0,6.2832); ctx.fill();
}
```
drawSnake(t, now):
```js
function drawSnake(now,t){
const n=body.length;
const pts=new Array(n);
for(let i=0;i<n;i++){
const from=i<prevBody.length?prevBody[i]:prevBody[prevBody.length-1];
const to=body[i];
pts[i]={x:(from.x+(to.x-from.x)*t+0.5)*cell, y:(from.y+(to.y-from.y)*t+0.5)*cell};
}
if(mode==='over'){
const dd=now-deathAt;
if(dd<330){
const b=Math.sin(Math.min(dd/330,1)*Math.PI)*0.3*cell;
pts[0].x+=deathDir.x*b; pts[0].y+=deathDir.y*b;
}
}
const through=()=>{ctx.beginPath();ctx.moveTo(pts[0].x,pts[0].y);for(let i=1;i<n;i++)ctx.lineTo(pts[i].x,pts[i].y);};
ctx.lineJoin='round'; ctx.lineCap='round';
// glow + rim
ctx.save();
ctx.shadowColor='rgba(74,222,128,0.5)'; ctx.shadowBlur=cell*0.7;
ctx.strokeStyle='#0e3320'; ctx.lineWidth=cell*0.84;
through(); ctx.stroke();
ctx.restore();
// gradient body
for(let i=n-1;i>0;i--){
const k=1-i/(n-1);
ctx.strokeStyle=`hsl(${152+8*k} ${52+18*k}% ${26+30*k}%)`;
ctx.lineWidth=cell*(0.58+0.12*k);
ctx.beginPath(); ctx.moveTo(pts[i].x,pts[i].y); ctx.lineTo(pts[i-1].x,pts[i-1].y); ctx.stroke();
}
// spine shading
ctx.strokeStyle='rgba(5,30,18,0.30)'; ctx.lineWidth=cell*0.14; through(); ctx.stroke();
// death flash
if(mode==='over'){
const dd=now-deathAt;
if(dd<320){ ctx.strokeStyle=`rgba(255,92,92,${0.5*(1-dd/320)})`; ctx.lineWidth=cell*0.84; through(); ctx.stroke(); }
}
drawHead(pts,now);
}
```
`hsl(152 52% 26%)` space-separated — modern Chrome supports space syntax in canvas? Canvas fillStyle parsing uses CSS color parsing — Chrome supports `hsl(152 52% 26%)` in CSS Color 4 — yes modern Chrome parses it. To be extra safe use commas: `hsl(${h}, ${s}%, ${l}%)`. Use commas — universally safe.
drawHead(pts, now):
```js
function drawHead(pts,now){
const hx=pts[0].x, hy=pts[0].y;
let dx=deathDir.x,dy=deathDir.y; // wait default should be dir
```
No: default `let dx=dir.x, dy=dir.y;` then refine from pts:
```js
let dx=dir.x,dy=dir.y;
if(pts.length>1){
const vx=hx-pts[1].x, vy=hy-pts[1].y;
const m=Math.hypot(vx,vy);
if(m>0.01){dx=vx/m;dy=vy/m;}
}
```
Hmm during death bump, pts[0] shifted toward deathDir → m grows in deathDir direction — head keeps "facing" correctly ✓. But during interpolation with t small, pts[0]-pts[1] tiny but direction correct (both move... at t=0, pts[0]==prevBody[0] and pts[1]==prevBody[1] → difference = old dir cell offset ✓ nonzero). Actually careful at t=0 exactly after a turn: prevBody[0], prevBody[1] — prev positions: head and neck — the offset is the OLD direction ✓ fine.
Edge: when eating growth, pts[n-1] etc fine.
Continue:
```js
const r=cell*0.44;
ctx.fillStyle='hsl(140,72%,62%)';
ctx.strokeStyle='hsl(152,55%,24%)';
ctx.lineWidth=Math.max(1.5,cell*0.09);
ctx.beginPath(); ctx.arc(hx,hy,r,0,6.2832); ctx.fill(); ctx.stroke();
ctx.fillStyle='rgba(255,255,255,0.16)';
ctx.beginPath(); ctx.arc(hx-r*0.28,hy-r*0.34,r*0.42,0,6.2832); ctx.fill();
// tongue
if(mode!=='over'){
const tf=now%2600;
if(tf<240){
const ext=Math.sin((tf/240)*Math.PI);
const px=-dy,py=dx;
const bx=hx+dx*r*0.92, by=hy+dy*r*0.92;
const mx=bx+dx*ext*cell*0.42, my=by+dy*ext*cell*0.42;
ctx.strokeStyle='#ff5d7e';
ctx.lineWidth=Math.max(1.4,cell*0.07); ctx.lineCap='round';
ctx.beginPath();
ctx.moveTo(bx,by); ctx.lineTo(mx,my);
ctx.lineTo(mx+dx*cell*0.13+px*cell*0.1, my+dy*cell*0.13+py*cell*0.1);
ctx.moveTo(mx,my);
ctx.lineTo(mx+dx*cell*0.13-px*cell*0.1, my+dy*cell*0.13-py*cell*0.1);
ctx.stroke();
}
}
// eyes
const px=-dy,py=dx;
const rot=Math.atan2(dy,dx);
const blink=(now%3600)>3480;
const eR=cell*0.115;
for(const s of [1,-1]){
const ex=hx+dx*cell*0.15+px*cell*0.185*s;
const ey=hy+dy*cell*0.15+py*cell*0.185*s;
ctx.fillStyle='#f2fbee';
ctx.beginPath(); ctx.ellipse(ex,ey,eR*(blink?0.25:1),eR,rot,0,6.2832); ctx.fill();
if(!blink){
const cx2=ex+dx*cell*0.045, cy2=ey+dy*cell*0.045;
ctx.fillStyle='#14261c';
ctx.beginPath(); ctx.arc(cx2,cy2,cell*0.055,0,6.2832); ctx.fill();
ctx.fillStyle='rgba(255,255,255,0.85)';
ctx.beginPath(); ctx.arc(cx2-cell*0.02,cy2-cell*0.02,cell*0.018,0,6.2832); ctx.fill();
}
}
}
```
Hmm ellipse rotation semantics: radiusX axis rotated by `rot`. radiusX = eR*blinkFactor — squashes along movement ✓.
drawFx(now) as planned. Set textAlign/baseline each call.
render(now):
```js
function render(now){
let t;
if(mode==='paused') t=Math.min(1,(pausedAt-lastTick)/tickMs);
else if(mode==='over') t=1;
else t=Math.min(1,(now-lastTick)/tickMs);
ctx.save();
if(mode==='over'){
const dd=now-deathAt;
if(dd<340){const m=(1-dd/340)*5; ctx.translate((Math.random()*2-1)*m,(Math.random()*2-1)*m);}
}
ctx.clearRect(-8,-8,W+16,H+16);
ctx.drawImage(bg,0,0,W,H);
if(food) drawFood(now);
drawSnake(now,t);
drawFx();
ctx.restore();
}
```
clearRect with translate applied: translated clearRect region — covers -8..W+16 in translated space; shake max 5px → ok. Actually simpler: clearRect before save/translate. Then bg drawn translated reveals transparent edges (dark page bg) — fine. Let me clearRect(0,0,W,H) before save. But note ctx.setTransform(dpr...) persists; clearRect(0,0,W,H) in css units ✓.
Order: clear → save → translate(shake) → drawImage(bg) etc → restore ✓.
frame loop:
```js
function frame(now){
requestAnimationFrame(frame);
if(!lastFrame) lastFrame=now;
const dt=Math.min(48,now-lastFrame); lastFrame=now;
if(mode==='playing'||mode==='demo'){
if(now-lastTick>1000) lastTick=now;
let guard=0;
while(now-lastTick>=tickMs && guard++<8){
lastTick+=tickMs;
step();
if(mode!=='playing'&&mode!=='demo') break;
}
}
if(mode!=='paused') updateFx(dt);
render(now);
}
```
Hmm: `if(now-lastTick>1000) lastTick=now;` — after this, while won't run; fine.
Edge: after pause resume, lastTick adjusted ✓; if user pauses immediately after tab switch... fine.
aiDir + flood as above.
events:
```js
window.addEventListener('keydown',(e)=>{
if(e.ctrlKey||e.metaKey||e.altKey) return;
const c=e.code;
if(mode==='demo'||(mode==='over'&&overFrom==='demo')){
if(!IGNORE_KEY.test(c)){e.preventDefault(); startGame();}
return;
}
if(c==='KeyP'){e.preventDefault(); if(mode==='playing') pauseGame(); else if(mode==='paused') resumeGame(); return;}
if(c==='Space'||c==='Enter'||c==='NumpadEnter'){
e.preventDefault();
if(mode==='over') startGame();
else if(mode==='paused') resumeGame();
return;
}
const d=KEYMAP[c];
if(d){
e.preventDefault();
if(mode==='paused') resumeGame();
if(mode==='playing') enqueue(DIRS[d]);
}
});
document.addEventListener('pointerdown',(e)=>{
if(mode==='demo'||(mode==='over'&&overFrom==='demo')){startGame();return;}
if(mode==='paused'){resumeGame();return;}
if(mode==='playing') swipe={x:e.clientX,y:e.clientY};
});
window.addEventListener('pointermove',(e)=>{
if(!swipe||mode!=='playing') return;
const dx=e.clientX-swipe.x, dy=e.clientY-swipe.y;
if(Math.hypot(dx,dy)>24){
enqueue(DIRS[Math.abs(dx)>Math.abs(dy)?(dx>0?'right':'left'):(dy>0?'down':'up')]);
swipe={x:e.clientX,y:e.clientY};
}
});
window.addEventListener('pointerup',()=>{swipe=null;});
window.addEventListener('pointercancel',()=>{swipe=null;});
window.addEventListener('resize',layout);
window.addEventListener('blur',()=>{if(mode==='playing') pauseGame();});
```
Auto-pause on blur — nice touch (iframe blur happens on click outside). But careful: blur fires when clicking outside iframe; on return user resumes via P/click ✓. Also `blur` on load? No.
Wait — potential issue: pointerdown starting the game — when clicking INSIDE iframe first time, fine.
Also: when the demo runs and user presses 'P' — starts game (any key). OK.
enqueue:
```js
function enqueue(d){
const ref=queue.length?queue[queue.length-1]:dir;
if(d.x===ref.x&&d.y===ref.y) return;
if(d.x===-ref.x&&d.y===-ref.y) return;
if(queue.length>=3) return;
queue.push(d);
}
```
pause/resume:
```js
function pauseGame(){
if(mode!=='playing') return;
mode='paused'; pausedAt=performance.now();
ovPause.classList.add('show');
}
function resumeGame(){
if(mode!=='paused') return;
lastTick+=performance.now()-pausedAt;
mode='playing';
ovPause.classList.remove('show');
}
```
layout + buildBG as planned:
```js
function layout(){
dpr=Math.min(2,window.devicePixelRatio||1);
const availW=Math.min(document.documentElement.clientWidth-24,1000);
for(let pass=0;pass<2;pass++){
const availH=window.innerHeight-hud.offsetHeight-foot.offsetHeight-48;
cell=Math.max(10,Math.min(32,Math.floor(Math.min(availW/COLS,availH/ROWS))));
W=cell*COLS; H=cell*ROWS;
hud.style.width=W+'px'; foot.style.width=W+'px';
}
canvas.style.width=W+'px'; canvas.style.height=H+'px';
canvas.width=Math.round(W*dpr); canvas.height=Math.round(H*dpr);
ctx.setTransform(dpr,0,0,dpr,0,0);
buildBG();
particles.length=0; rings.length=0; pops.length=0;
}
```
Hmm clearing fx on every resize (also initial — fine, empty anyway).
Wait — clearing pops on resize kills the GO popup if resize... rare, fine.
Init:
```js
layout();
resetGame(true);
requestAnimationFrame(frame);
```
But resetGame pushes GO only for player; demo none. ✓
One issue: resetGame sets `document.body.classList.toggle('demo',demo)` — demo=true → demo chip shows ✓.
Check drawFx font uses cell — recompute per frame fine.
Check for potential error: in drawSnake, `prevBody[prevBody.length-1]` when prevBody empty? prevBody always set at reset and step ✓ length≥4.
`ctx.ellipse` requires radii ≥0 ✓.
One more: on `die()` during demo — `ovStart.classList.remove('show')` then resetGame(true) re-adds after 1.6s — with transition, the card fades out/in ✓.
Also during that window body.demo class remains true → demo chip still shows while mode==='over' — fine.
Also keydown: mode==='over' && overFrom==='demo' → startGame ✓.
Potential subtle bug: `resetGame` sets mode AFTER placeFood — during placeFood→winGame mode check irrelevant. But `lastTick=performance.now()` after — ok. Also set mode before? placeFood can't win on fresh board. Keep order but set mode early for cleanliness: set mode right after isDemoRun. Let me reorder: set mode first, then everything. I'll set `mode = demo?'demo':'playing';` early.
Hmm but resetGame called from demoTimer — mode was 'over' → sets 'demo' ✓.
Also: when player presses key during demo mid-frame — startGame → resetGame → mode 'playing' ✓ overlay hides via class ✓.
Now check the `while` loop uses tickMs possibly updated inside step (speed up after eat) — `lastTick+=tickMs` uses updated? Sequence: check condition with old tickMs, add tickMs (old value read at loop iteration — after step, tickMs changed; next iteration condition uses new). Since lastTick += tickMs evaluated after step? No: order in loop body: lastTick+=tickMs; step(); — I wrote lastTick+=tickMs BEFORE step(). Let me keep: lastTick+=tickMs; step(); — so speed change affects subsequent iterations ✓.
Everything seems consistent. Let me also add a nice touch: subtle food shadow on board? skip.
Also one more requirement re-read: "Display the current score and the best score of the session." ✓ chips.
"Game over when the snake hits a wall or itself, with an overlay showing the score" ✓.
Wait — one more thought on "Quick successive key presses must both be honored": Consider pressing Up then Left within the same tick: queue=[Up,Left]; tick1 applies Up (dir=Up), tick2 applies Left. Both honored ✓. Without queue (naive last-wins), Left would replace Up and snake would go Left immediately possibly reversing — our queue solves ✓.
Test scenario: dir=Right; press Up, then Down quickly. Up queued (ref=Right, ok). Down: ref=Up → Down is reverse of Up → ignored. Correct (can't reverse). The player intent "down" is invalid while moving right anyway... pressing Up then Down means net cancel — ignoring Down is right, otherwise snake would reverse. ✓
Another: dir=Right; press Left → ignored ✓.
Now double-check color of body: at k=1 (nearest head): hsl(160,70%,56%) vs head fill hsl(140,72%,62%) — head slightly different hue — distinct ✓ plus darker outline & eyes.
Rim color '#0e3320' (very dark green) — width 0.84 vs body 0.58-0.70 → visible dark outline ~0.07cell each side ✓.
The spine line rgba(5,30,18,0.30) width 0.14 — subtle ✓.
BG '#0f1a14'; snake dark rim stands out; food red pops ✓.
Board bg vs page bg: page #0a0f0c vs board #0f1a14 + vignette — subtle difference ✓.
Now — one more important thing: "smooth movement" — also ensure the tick count doesn't visually stutter due to while-loop multi-steps at low FPS — fine at 60fps.
Potential perf: per-segment strokes with n up to hundreds (long snake) — each a stroke; n=200 → 200 strokes/frame + shadow pass — fine for canvas.
Flood fill Set allocations per AI tick — fine.
Let me also double check `render` draw of overlays is CSS — ✓.
Let me reconsider the initial visible layout: ovStart shows big title "SNAKE" + CTA. The HUD also shows "SNAKE" brand — duplicated. Maybe make HUD brand just the snake SVG + "SNAKE" small — it's fine, common.
Actually — reduce redundancy: start card title "SNAKE" is the hero; keep both (game title in HUD needed anyway).
Alternatively start card kicker "AI is playing — jump in". I'll finalize copy:
- kicker: "Watch or play"
- title: "SNAKE"
- sub: "The snake is playing itself in the background."
- cta: "Click or press a key to start"
- keys: "↑↓←→ / WASD to steer · P to pause"
Footer keeps controls too. OK.
Now — verify no `alert/confirm` etc ✓. No localStorage ✓. No external requests ✓ (system fonts only).
Let me also double-check CSS `100dvh` support — Chrome ✓. Fallback: set `height:100vh` before `100dvh` (I'll include both lines).
Canvas borderRadius 14px with box-shadow ✓ overlays also borderRadius 14 matching stage inset 0 — stage is exactly canvas size (display:block canvas, stage shrink-wrap? A div's width defaults to fill container — #app is flex column align-items:center → children shrink-to-fit? In a column flex container, cross-axis (width) is determined by content for align-items:center ✓ stage width = canvas width ✓.
Hmm wait: #stage contains canvas (block, fixed px width) and overlays (absolute) → stage width = max child width = canvas width ✓.
Also `#app` children: hud (width set px), stage (auto), foot (width set px) — all centered ✓.
Alright — one more consideration about `window.innerHeight` in sandboxed iframe: fine.
Also `document.documentElement.clientWidth` ✓.
Potential vertical overflow if hud wraps at medium widths making total exceed? layout's two-pass handles approx. Plus body overflow hidden. Board might get clipped at extreme cases — acceptable.
Let me write final polished code, being careful with syntax. I'll aim for clean organized script with comments.
Also — small detail: `ctx.lineJoin='round'` set in drawSnake each frame; drawFx sets lineJoin for strokeText ✓.
drawFx:
```js
function drawFx(){
for(let i=0;i<particles.length;i++){
const p=particles[i];
const a=Math.max(0,1-p.life/p.max);
ctx.globalAlpha=a;
ctx.fillStyle=p.color;
ctx.beginPath(); ctx.arc(p.x,p.y,p.size*(0.4+0.6*a),0,6.2832); ctx.fill();
}
for(let i=0;i<rings.length;i++){
const rg=rings[i], e=rg.life/rg.max;
ctx.globalAlpha=(1-e)*0.65;
ctx.strokeStyle='#ffa46a';
ctx.lineWidth=Math.max(1,3.2*(1-e));
ctx.beginPath(); ctx.arc(rg.x,rg.y,cell*(0.35+easeOut(e)*1.15),0,6.2832); ctx.stroke();
}
ctx.textAlign='center'; ctx.textBaseline='middle';
for(let i=0;i<pops.length;i++){
const q=pops[i], e=q.life/q.max;
ctx.globalAlpha=(1-e)*(1-e);
ctx.font=`700 ${Math.max(12,Math.round(cell*0.62))}px ui-monospace, Menlo, Consolas, monospace`;
ctx.lineWidth=4; ctx.lineJoin='round';
ctx.strokeStyle='rgba(6,20,13,0.85)';
ctx.strokeText(q.text,q.x,q.y);
ctx.fillStyle='#d9ffe6';
ctx.fillText(q.text,q.x,q.y);
}
ctx.globalAlpha=1;
}
```
Note: font with 'ui-monospace' first — if unsupported parsing, whole font string invalid → default font. Chrome supports ui-monospace generic in canvas? CSS-wise yes (Chrome 130+ has ui-monospace). Older Chrome? ui-monospace shipped Chrome 128? Hmm risk. Safer: `Menlo, Consolas, monospace` — skip ui-monospace in canvas. DOM CSS can use it safely (falls back at parse). I'll use `'700 14px Menlo, Consolas, monospace'` — wait `monospace` generic fine. Windows has Consolas ✓ Mac Menlo ✓ Linux generic ✓.
The '+10' pop might be drawn under the snake (food is drawn before snake; particles after snake — pop text after snake ✓ visible).
Now, assemble everything. Also let me add board inner top highlight? skip.
Double-check `IGNORE_KEY` regex with 'Space'? Space not ignored ✓ starts game from demo ✓.
Wait — in demo, pressing Space starts game — and Space keydown in demo branch calls startGame then returns ✓.
Edge: mode 'over' from playing, pressing 'KeyP' → branch `c==='KeyP'` → mode==='over' → neither pause nor resume → nothing ✓.
I think we're set. One more: auto-pause on window blur — when iframe loads without focus... does 'blur' fire on load? Only if focus lost. Initially iframe may not have focus; blur event won't fire (no prior focus). Fine.
Also: game starts in demo — if the iframe never gets focus, rAF still runs ✓.
Let me also reconsider: `window.addEventListener('blur', ...)` — if user is playing and clicks outside iframe → pause + overlay "PAUSED" ✓ good UX.
Hmm, but careful: pointerdown to start also gives focus; subsequent blur pause is fine.
One risk: When user clicks the iframe (focus), some sandboxes auto-focus... irrelevant.
Now — writing final answer. Let me make sure IDs referenced in JS exist in HTML: game, stage, hud, foot, scoreVal, bestVal, scoreChip, ovStart, ovPause, ovOver, ovOverTitle, finalScore, finalBest, newBestTag, demoChip (not referenced in JS — only CSS body.demo). ✓
Also `pops` variable name and function... fine.
Final check on `resetGame` — I set `document.body.classList.toggle('demo', demo)` ✓ and overlays.
Also initial HTML: body has no 'demo' class but ovStart has 'show' and demoChip hidden until JS adds class — first paint pre-JS: demo chip hidden, start overlay shown ✓. JS runs immediately (script at end) → class added before first rAF paint basically ✓.
Let me also give the start overlay a tiny "hint" that AI is playing: the demo chip ✓ + sub text ✓.
Also: possible improvement — during demo, dim board slightly? The overlay bg rgba(5,9,7,.28) dims ✓.
Write it all out now. Also double check there's no usage of `food` after winGame (food=null; render guards `if(food)`; aiDir guards food null? aiDir: `const eating = food && ...` ✓; dist guarded `food?...:0` ✓; onEat uses food before nulling ✓; step's eating check `food && ...` ✓).
In step, if food null (post-win) — mode already 'over' so no step. ✓
Also `tickMs` while-loop: after win/die, break ✓.
Another consideration — the guard `<8` loop iterations: with tickMs≥68 and frame 16ms, max 1 iteration typically ✓.
I also want to double check the shake translate doesn't clip overlay (DOM) — overlays are DOM, unaffected ✓.
Also the death bump for wall death — deathDir points into wall; pts[0] moves 0.3 cell into wall briefly — dramatic ✓.
For self-collision death — bump toward own body — overlapping briefly ✓ fine.
Let me write the final file.
Also CSS: `.ov-title` gradient text needs `display:inline-block`? background-clip text works on block elements ✓ (h1 in HUD too).
`h1` gradient: with `color:transparent` and background-clip ✓.
Edge: Some browsers need `-webkit-text-fill-color`? color:transparent works.
Now produce final HTML. Let me also count roughly the total — it'll be ~500 lines. Fine.
One last detail — `.chip .value` inline for pop transform: add `display:inline-block` (span default inline; transform on inline doesn't apply!). `.value{display:inline-block}` ✓ important. Or use `display:block` since chip is flex column — flex items are blockified anyway! .chip is flex column → children become flex items → transforms work ✓. Still add inline-block harmless. Actually flex items: yes transformable ✓.
`popEl` re-trigger with void offsetWidth ✓.
Also `@keyframes blink` conflicts name with nothing else ✓.
Media query small: reduce chip min-width 64px; hud gap 8.
Let me also ensure no horizontal scrollbar at 375: app padding 12 + board 338 = 362 ≤375 ✓.
Also for very narrow (<360), cell min 10 → board 260 wide ✓.
I think the code is solid. Write it.
Actually — one more think about `layout()` initial call: `hud.offsetHeight` — at initial layout the fonts might not be loaded (system fonts — immediate) ✓.
Also `foot` width set — foot content may overflow width if board narrow (footer hints wrap) ✓ flex-wrap.
Final: write answer with one fenced code block.
Let me write the complete file carefully now, keeping syntax valid.
Additional small polish I'll include: `canvas{cursor:pointer}` in demo mode? body.demo canvas cursor pointer — hint clickable. `body.demo #stage{cursor:pointer}` ✓.
Also selection/user-select disabled ✓.
Writing now. I'll be meticulous about matching braces.
One more idea: add slight animation to demo chip label — no, fine.
Also I realize: in `drawSnake`, when mode==='over', I compute pts with t=1 — body/preBody equal — pts static ✓.
And on pause — snake drawn with t from pausedAt ✓ static ✓.
Let me also handle rapid restart: Space during 'over' before overlay timeout fires: startGame clears overTimer ✓ (resetGame clears both timers) ✓.
Also `startGame` when mode==='paused' via pointerdown? pointerdown handles paused → resume ✓. keydown: any key in paused: only direction keys resume (and Space/Enter/P). If user presses 'K' while paused → nothing. Fine.
Also — pressing 'Space' during 'playing': nothing (avoid accidental restarts) ✓.
OK writing final.
Also one more nuance: In `frame`, when `mode==='over'` (post-death), `render` uses t=1 and pts computed from prevBody/body — but after die() during step, prevBody was JUST snapshotted (top of step) and body unchanged → prevBody==body content → static ✓ good (bump still animates via deathDir).
Wait — there's a subtle thing in step(): prevBody snapshot happens BEFORE die check. So on death tick: prevBody = copy of body. render over: t=1, from==to → static at last committed cell ✓ + bump ✓.
And what about the partial animation of the FINAL successful move — it completed in the tick where death happened? Sequence: move at tick T commits body update; render interpolates during [T, T+tick]. At tick T+1 step() → death. So the snake completed its previous move fully ✓.
Also what about t clamping at exactly the boundary: `t=Math.min(1,(now-lastTick)/tickMs)` for 'playing' — after death mode='over' → t=1 ✓.
Good. Also during 'over', particles still update (updateFx runs unless paused) ✓ and drawFx shows ✓ — leftover '+10' finishes fading ✓.
Alright, final code. I'll also add small comments for readability.
Actually, let me double check `hsl()` string generation: `hsl(${152+8*k}, ${52+18*k}%, ${26+30*k}%)` → e.g., k=0.5 → `hsl(156, 61%, 41%)` ✓.
The stroke of segments: lineCap round → at each joint the newer (closer-to-head) segment overlaps. Wait — draw order: i from n-1 down to 1: first drawn is the tail-most segment (i=n-1: from pts[n-1] to pts[n-2]), last drawn is i=1 (pts[1]→pts[0]) — head-side drawn last on top ✓.
Also single-segment edge: n≥4 always ✓.
Consider glow pass with shadow — stroke color '#0e3320' — glow color greenish around dark rim — hmm, glow around a dark stroke gives green halo ✓ nice.
Maybe also glow the head slightly more? head has its own fill; ok.
Let me also confirm `ctx.shadowBlur=cell*0.7` ~ 21px at cell 30 — soft halo ✓. Performance ok (one stroke per frame with shadow).
Everything set. Write the final document.
Also: title element "Snake" — page title fine.
Let me write HTML now, complete.
```html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Snake</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
html, body { height: 100%; }
body {
height: 100vh; height: 100dvh; overflow: hidden;
background: #0a0f0c;
background-image: radial-gradient(1100px 640px at 50% 6%, rgba(74,222,128,.09), transparent 62%);
color: #e7f4ea;
font-family: ui-sans-serif, system-ui, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
display: flex; align-items: center; justify-content: center;
-webkit-user-select: none; user-select: none;
}
#app { display: flex; flex-direction: column; align-items: center; gap: 12px; padding: 12px; }
#hud { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; }
.brand { display: flex; align-items: center; gap: 10px; }
h1 {
font-size: clamp(17px, 2.3vw, 23px); font-weight: 800;
letter-spacing: .38em; margin-right: -.38em;
background: linear-gradient(92deg, #c9f8d4, #4ade80 55%, #2f9e63);
-webkit-background-clip: text; background-clip: text; color: transparent;
}
.stats { display: flex; gap: 8px; }
.chip {
display: flex; flex-direction: column; align-items: center; gap: 1px; min-width: 72px;
padding: 5px 12px; border-radius: 11px;
background: rgba(255,255,255,.035); border: 1px solid rgba(255,255,255,.09);
transition: border-color .3s, box-shadow .3s;
}
.chip .label { font-size: 9px; letter-spacing: .18em; text-transform: uppercase; color: #8fa898; }
.chip .value {
font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
font-size: 16px; font-weight: 700; line-height: 1.2;
}
.chip.record { border-color: rgba(183,247,205,.55); box-shadow: 0 0 18px rgba(74,222,128,.28); }
.chip.record .value { color: #b7f7cd; }
.value.pop { animation: pop .32s ease; }
@keyframes pop { 0% { transform: scale(1); } 40% { transform: scale(1.28); } 100% { transform: scale(1); } }
#demoChip { display: none; flex-direction: row; align-items: center; gap: 7px; min-width: 0; }
body.demo #demoChip { display: flex; }
.dot {
width: 7px; height: 7px; border-radius: 50%; background: #fbbf24;
box-shadow: 0 0 10px rgba(251,191,36,.9); animation: blink 1.15s ease-in-out infinite;
}
@keyframes blink { 50% { opacity: .25; } }
#stage { position: relative; }
body.demo #stage { cursor: pointer; }
canvas {
display: block; border-radius: 14px; background: #0f1a14; touch-action: none;
box-shadow: 0 0 0 1px rgba(134,239,172,.18), 0 30px 70px rgba(0,0,0,.55), 0 0 90px rgba(74,222,128,.06);
}
.overlay {
position: absolute; inset: 0; display: flex; align-items: center; justify-content: center;
background: rgba(5,9,7,.28); border-radius: 14px;
opacity: 0; visibility: hidden; pointer-events: none;
transition: opacity .35s ease, visibility .35s;
}
.overlay.show { opacity: 1; visibility: visible; }
.card {
background: rgba(10,17,13,.72); border: 1px solid rgba(134,239,172,.2); border-radius: 18px;
padding: 20px 30px; text-align: center;
backdrop-filter: blur(9px); -webkit-backdrop-filter: blur(9px);
box-shadow: 0 24px 60px rgba(0,0,0,.5);
transform: translateY(8px) scale(.97); transition: transform .35s cubic-bezier(.2,.9,.25,1.2);
}
.overlay.show .card { transform: none; }
.ov-kicker { font-size: 10px; letter-spacing: .34em; text-transform: uppercase; color: #8fa898; margin-bottom: 6px; }
.ov-title {
font-size: clamp(26px, 4vw, 38px); font-weight: 800; letter-spacing: .3em; margin-right: -.3em;
background: linear-gradient(92deg, #c9f8d4, #4ade80 60%, #2f9e63);
-webkit-background-clip: text; background-clip: text; color: transparent;
}
.ov-title.sm { font-size: clamp(20px, 3vw, 26px); }
.ov-title.danger {
background: linear-gradient(92deg, #ffcbcb, #f87171 60%, #b91c1c);
-webkit-background-clip: text; background-clip: text; color: transparent;
}
.ov-sub { margin-top: 8px; font-size: 13px; color: #8fa898; }
.ov-sub b { color: #d9ffe6; font-family: ui-monospace, Menlo, Consolas, monospace; }
.cta {
margin-top: 16px; display: inline-flex; align-items: center; gap: 8px;
padding: 9px 18px; border-radius: 999px; font-size: 13px; font-weight: 600; color: #d9ffe6;
background: rgba(74,222,128,.12); border: 1px solid rgba(134,239,172,.4);
animation: ctaPulse 2s ease-in-out infinite;
}
@keyframes ctaPulse { 50% { box-shadow: 0 0 22px rgba(74,222,128,.35); border-color: rgba(183,247,205,.75); } }
.ov-keys { margin-top: 12px; font-size: 11px; color: #8fa898; }
.final { margin-top: 14px; }
.fl-label { display: block; font-size: 10px; letter-spacing: .26em; text-transform: uppercase; color: #8fa898; }
.fl-value {
font-family: ui-monospace, Menlo, Consolas, monospace;
font-size: 42px; font-weight: 800; color: #d9ffe6; line-height: 1.15;
}
.newbest { margin-top: 4px; font-size: 11px; font-weight: 700; letter-spacing: .14em; color: #fbbf24; text-transform: uppercase; }
kbd {
display: inline-block; min-width: 20px; padding: 2px 6px; border-radius: 6px;
background: rgba(255,255,255,.07); border: 1px solid rgba(255,255,255,.14); border-bottom-width: 2px;
font-family: ui-monospace, Menlo, Consolas, monospace; font-size: 11px; color: #cfe8d6;
}
#foot { display: flex; align-items: center; justify-content: center; gap: 16px; flex-wrap: wrap; font-size: 11.5px; color: #8fa898; }
.hint { display: inline-flex; align-items: center; gap: 5px; }
@media (max-width: 520px) {
#hud { justify-content: center; }
.chip { min-width: 64px; }
#foot { gap: 10px; }
}
</style>
</head>
<body class="demo">
...
```
Wait — body class="demo" in markup so demo chip shows pre-JS ✓.
Now the body content + script. Let me finalize the script in full and re-verify every referenced symbol.
I'm also going to double check the `ov-keys` line and CTA content for start card.
Alright, compose everything. In the script, order: constants, DOM refs, state, util fns, game fns, render fns, input, init.
Also note `const cy = ROWS >> 1;` = 11 ✓ (ROWS=22). Body x from 12 down to 9, y=11.
placeFood initial random — could spawn adjacent to head; fine.
Let me write the final code block. Also add `aria-hidden` to decorative svg ✓.
Double-check `finalBest` shows best (already includes live update). At die(): bestVal already shows best ✓.
Also in die() for playing, the overTimer callback checks `mode!=='over'||overFrom!=='playing'` guard then fills. ✓
One thing: `newBestTag.hidden=!newRecord` — newRecord computed as `score>startBest`. If player beats best mid-run then dies: score>startBest ✓ shows. If startBest was 0 and score 10 → shows "New session best" — correct (first ever run).
Edge: score 0 death → newRecord false ✓.
Now, also verify: `ovOverTitle.textContent='GAME OVER'` reset each death (in case win changed it) ✓.
Write it.
Let me write the JS fully, then review once more line by line.
```js
(() => {
'use strict';
// ---------- constants ----------
const COLS = 26, ROWS = 22;
const BASE_TICK = 132, MIN_TICK = 68, TICK_STEP = 2.4;
const DIRS = { up:{x:0,y:-1}, down:{x:0,y:1}, left:{x:-1,y:0}, right:{x:1,y:0} };
const KEYMAP = {
ArrowUp:'up', KeyW:'up', ArrowDown:'down', KeyS:'down',
ArrowLeft:'left', KeyA:'left', ArrowRight:'right', KeyD:'right'
};
const IGNORE_KEY = /^(Shift|Control|Alt|Meta|CapsLock|NumLock|ScrollLock|Escape|Tab|ContextMenu|F\d)/;
const AI_DIRS = [DIRS.right, DIRS.left, DIRS.down, DIRS.up];
const PCOLORS = ['#ff6b5e', '#ffb35c', '#ffe066', '#8ce99a', '#ff8fa3'];
// ---------- dom ----------
const canvas = document.getElementById('game');
const ctx = canvas.getContext('2d');
const hud = document.getElementById('hud');
const foot = document.getElementById('foot');
const scoreVal = document.getElementById('scoreVal');
const bestVal = document.getElementById('bestVal');
const scoreChip = document.getElementById('scoreChip');
const ovStart = document.getElementById('ovStart');
const ovPause = document.getElementById('ovPause');
const ovOver = document.getElementById('ovOver');
const ovOverTitle = document.getElementById('ovOverTitle');
const finalScore = document.getElementById('finalScore');
const finalBest = document.getElementById('finalBest');
const newBestTag = document.getElementById('newBestTag');
// ---------- state ----------
let cell = 24, W = 0, H = 0, dpr = 1, bg = null;
let body = [], prevBody = [], dir = DIRS.right, queue = [];
let food = null, foodAt = 0;
let mode = 'demo', isDemoRun = true, overFrom = 'demo';
let score = 0, eaten = 0, best = 0, startBest = 0, tickMs = 118;
let lastTick = 0, pausedAt = 0, deathAt = 0, deathDir = { x: 1, y: 0 };
let particles = [], rings = [], pops = [];
let demoTimer = 0, overTimer = 0, lastFrame = 0, swipe = null;
// ---------- helpers ----------
const easeOut = e => 1 - Math.pow(1 - e, 3);
const easeOutBack = x => { const c1 = 1.70158, c3 = c1 + 1; return 1 + c3 * Math.pow(x - 1, 3) + c1 * Math.pow(x - 1, 2); };
function popEl(el) { el.classList.remove('pop'); void el.offsetWidth; el.classList.add('pop'); }
// ---------- layout ----------
function layout() {
dpr = Math.min(2, window.devicePixelRatio || 1);
const availW = Math.min(document.documentElement.clientWidth - 24, 1000);
for (let pass = 0; pass < 2; pass++) {
const availH = window.innerHeight - hud.offsetHeight - foot.offsetHeight - 48;
cell = Math.max(10, Math.min(32, Math.floor(Math.min(availW / COLS, availH / ROWS))));
W = cell * COLS; H = cell * ROWS;
hud.style.width = W + 'px';
foot.style.width = W + 'px';
}
canvas.style.width = W + 'px';
canvas.style.height = H + 'px';
canvas.width = Math.round(W * dpr);
canvas.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
buildBG();
particles.length = 0; rings.length = 0; pops.length = 0;
}
function buildBG() {
bg = document.createElement('canvas');
bg.width = Math.round(W * dpr); bg.height = Math.round(H * dpr);
const b = bg.getContext('2d');
b.setTransform(dpr, 0, 0, dpr, 0, 0);
b.fillStyle = '#0f1a14';
b.fillRect(0, 0, W, H);
b.fillStyle = 'rgba(255,255,255,0.022)';
for (let y = 0; y < ROWS; y++)
for (let x = (y & 1); x < COLS; x += 2)
b.fillRect(x * cell, y * cell, cell, cell);
const g = b.createRadialGradient(W/2, H/2, Math.min(W,H)*0.28, W/2, H/2, Math.max(W,H)*0.72);
g.addColorStop(0, 'rgba(0,0,0,0)');
g.addColorStop(1, 'rgba(0,0,0,0.32)');
b.fillStyle = g;
b.fillRect(0, 0, W, H);
}
```
Checker: start x=(y&1), step 2 → alternate rows ✓.
Continue game functions:
```js
// ---------- game core ----------
function resetGame(demo) {
isDemoRun = demo;
mode = demo ? 'demo' : 'playing';
clearTimeout(demoTimer); clearTimeout(overTimer);
demoTimer = 0; overTimer = 0;
queue.length = 0; particles.length = 0; rings.length = 0; pops.length = 0;
const cy = ROWS >> 1;
body = [];
for (let i = 0; i < 4; i++) body.push({ x: 12 - i, y: cy });
prevBody = body.map(c => ({ x: c.x, y: c.y }));
dir = DIRS.right;
score = 0; eaten = 0; startBest = best;
tickMs = demo ? 118 : BASE_TICK;
scoreVal.textContent = '0';
scoreChip.classList.remove('record');
document.body.classList.toggle('demo', demo);
ovStart.classList.toggle('show', demo);
ovPause.classList.remove('show');
ovOver.classList.remove('show');
placeFood();
lastTick = performance.now();
if (!demo) pops.push({ x: (body[0].x + 0.5) * cell, y: (body[0].y - 0.5) * cell, life: 0, max: 700, text: 'GO!' });
}
function startGame() { resetGame(false); }
function placeFood() {
const occ = new Set(body.map(c => c.y * COLS + c.x));
const free = [];
for (let y = 0; y < ROWS; y++)
for (let x = 0; x < COLS; x++) {
const k = y * COLS + x;
if (!occ.has(k)) free.push({ x, y });
}
if (!free.length) { food = null; winGame(); return; }
food = free[(Math.random() * free.length) | 0];
foodAt = performance.now();
}
function step() {
if (mode === 'demo') { const d = aiDir(); if (d) dir = d; }
else if (queue.length) dir = queue.shift();
prevBody = body.map(c => ({ x: c.x, y: c.y }));
const nh = { x: body[0].x + dir.x, y: body[0].y + dir.y };
if (nh.x < 0 || nh.y < 0 || nh.x >= COLS || nh.y >= ROWS) { die(); return; }
const eating = food && nh.x === food.x && nh.y === food.y;
const lim = eating ? body.length : body.length - 1;
for (let i = 0; i < lim; i++) {
if (body[i].x === nh.x && body[i].y === nh.y) { die(); return; }
}
body.unshift(nh);
if (eating) onEat(); else body.pop();
}
function onEat() {
const fx = (food.x + 0.5) * cell, fy = (food.y + 0.5) * cell;
score += 10; eaten++;
scoreVal.textContent = score;
popEl(scoreVal);
if (!isDemoRun && score > best) {
best = score;
bestVal.textContent = best;
popEl(bestVal);
scoreChip.classList.add('record');
}
tickMs = Math.max(MIN_TICK, (isDemoRun ? 118 : BASE_TICK) - eaten * TICK_STEP);
burst(fx, fy);
rings.push({ x: fx, y: fy, life: 0, max: 420 });
pops.push({ x: fx, y: fy - cell * 0.2, life: 0, max: 800, text: '+10' });
placeFood();
}
function die() {
mode = 'over';
overFrom = isDemoRun ? 'demo' : 'playing';
deathAt = performance.now();
deathDir = { x: dir.x, y: dir.y };
queue.length = 0;
scoreChip.classList.remove('record');
if (overFrom === 'demo') {
ovStart.classList.remove('show');
demoTimer = setTimeout(() => { demoTimer = 0; if (mode === 'over') resetGame(true); }, 1600);
} else {
const newRecord = score > startBest;
ovOverTitle.textContent = 'GAME OVER';
overTimer = setTimeout(() => {
overTimer = 0;
if (mode !== 'over' || overFrom !== 'playing') return;
finalScore.textContent = score;
finalBest.textContent = best;
newBestTag.hidden = !newRecord;
ovOver.classList.add('show');
}, 650);
}
}
function winGame() {
mode = 'over';
overFrom = isDemoRun ? 'demo' : 'playing';
deathAt = performance.now();
deathDir = { x: 0, y: 0 };
if (overFrom === 'demo') {
demoTimer = setTimeout(() => { demoTimer = 0; if (mode === 'over') resetGame(true); }, 1500);
} else {
finalScore.textContent = score;
finalBest.textContent = best;
newBestTag.hidden = !(score > startBest);
ovOverTitle.textContent = 'PERFECT!';
ovOver.classList.add('show');
}
}
function pauseGame() {
if (mode !== 'playing') return;
mode = 'paused';
pausedAt = performance.now();
ovPause.classList.add('show');
}
function resumeGame() {
if (mode !== 'paused') return;
lastTick += performance.now() - pausedAt;
mode = 'playing';
ovPause.classList.remove('show');
}
function enqueue(d) {
const ref = queue.length ? queue[queue.length - 1] : dir;
if (d.x === ref.x && d.y === ref.y) return;
if (d.x === -ref.x && d.y === -ref.y) return;
if (queue.length >= 3) return;
queue.push(d);
}
// ---------- demo AI ----------
function aiDir() {
const head = body[0];
let bestD = null, bestS = -Infinity;
for (const d of AI_DIRS) {
if (d.x === -dir.x && d.y === -dir.y) continue;
const nx = head.x + d.x, ny = head.y + d.y;
if (nx < 0 || ny < 0 || nx >= COLS || ny >= ROWS) continue;
const eating = food && nx === food.x && ny === food.y;
const blocked = new Set();
const lim = eating ? body.length : body.length - 1;
for (let i = 0; i < lim; i++) blocked.add(body[i].y * COLS + body[i].x);
if (blocked.has(ny * COLS + nx)) continue;
const area = flood(nx, ny, blocked);
const dist = food ? Math.abs(nx - food.x) + Math.abs(ny - food.y) : 0;
const s = (area >= body.length ? 1e6 + 500 - dist * 8 : area * 40 - dist * 8) + Math.random() * 3;
if (s > bestS) { bestS = s; bestD = d; }
}
return bestD;
}
function flood(sx, sy, blocked) {
const seen = new Uint8Array(COLS * ROWS);
const stack = [sy * COLS + sx];
seen[sy * COLS + sx] = 1;
let count = 0;
while (stack.length) {
const idx = stack.pop();
count++;
const x = idx % COLS, y = (idx / COLS) | 0;
if (x > 0 && !seen[idx - 1] && !blocked.has(idx - 1)) { seen[idx - 1] = 1; stack.push(idx - 1); }
if (x < COLS - 1 && !seen[idx + 1] && !blocked.has(idx + 1)) { seen[idx + 1] = 1; stack.push(idx + 1); }
if (y > 0 && !seen[idx - COLS] && !blocked.has(idx - COLS)) { seen[idx - COLS] = 1; stack.push(idx - COLS); }
if (y < ROWS - 1 && !seen[idx + COLS] && !blocked.has(idx + COLS)) { seen[idx + COLS] = 1; stack.push(idx + COLS); }
}
return count;
}
```
fx, render:
```js
function burst(x, y) {
const s = cell / 28;
for (let i = 0; i < 16; i++) {
const a = Math.random() * Math.PI * 2;
const sp = (0.05 + Math.random() * 0.17) * s;
particles.push({
x, y,
vx: Math.cos(a) * sp, vy: Math.sin(a) * sp - 0.03 * s,
life: 0, max: 380 + Math.random() * 320,
size: cell * (0.05 + Math.random() * 0.075),
color: PCOLORS[(Math.random() * PCOLORS.length) | 0]
});
}
}
function updateFx(dt) {
const s = cell / 28;
for (let i = particles.length - 1; i >= 0; i--) {
const p = particles[i];
p.life += dt;
if (p.life >= p.max) { particles.splice(i, 1); continue; }
const f = Math.exp(-dt / 420);
p.vx *= f;
p.vy = p.vy * f + 0.00035 * s * dt;
p.x += p.vx * dt; p.y += p.vy * dt;
}
for (let i = rings.length - 1; i >= 0; i--) {
rings[i].life += dt;
if (rings[i].life >= rings[i].max) rings.splice(i, 1);
}
for (let i = pops.length - 1; i >= 0; i--) {
const q = pops[i];
q.life += dt;
q.y -= dt * 0.028 * s;
if (q.life >= q.max) pops.splice(i, 1);
}
}
```
drawFood / drawSnake / drawHead / drawFx / render / frame as drafted.
Then input handlers, then init: layout(); resetGame(true); requestAnimationFrame(frame);
Wait — one thing: resetGame(true) called at init sets ovStart show (already shown) ✓ and body.demo ✓.
Also initial `mode='demo'` matches.
Let me also double check `render` clearRect before translate:
```js
function render(now) {
let t;
if (mode === 'paused') t = Math.min(1, (pausedAt - lastTick) / tickMs);
else if (mode === 'over') t = 1;
else t = Math.min(1, (now - lastTick) / tickMs);
ctx.clearRect(0, 0, W, H);
ctx.save();
if (mode === 'over') {
const dd = now - deathAt;
if (dd < 340) {
const m = (1 - dd / 340) * 5;
ctx.translate((Math.random() * 2 - 1) * m, (Math.random() * 2 - 1) * m);
}
}
ctx.drawImage(bg, 0, 0, W, H);
if (food) drawFood(now);
drawSnake(now, t);
drawFx();
ctx.restore();
}
```
Good. `bg` guaranteed built by layout before first frame ✓.
frame:
```js
function frame(now) {
requestAnimationFrame(frame);
if (!lastFrame) lastFrame = now;
const dt = Math.min(48, now - lastFrame);
lastFrame = now;
if (mode === 'playing' || mode === 'demo') {
if (now - lastTick > 1000) lastTick = now;
let guard = 0;
while (now - lastTick >= tickMs && guard++ < 8) {
lastTick += tickMs;
step();
if (mode !== 'playing' && mode !== 'demo') break;
}
}
if (mode !== 'paused') updateFx(dt);
render(now);
}
```
drawSnake full (as drafted) — one fix: variable `n` used in `through` closure ✓.
Also inside drawSnake, when computing pts and mode 'over' — note pts[0] bump uses deathDir possibly {0,0} (win) ✓.
Input handlers as drafted. blur pause added.
Check: `window.addEventListener('resize', layout)` ✓.
I realize one more potential issue: on `pointerdown` we call `startGame()` — if user clicks during 'over' (playing) expecting restart by click? Spec says Space/Enter restart; clicking on game-over could also restart — I'll add: pointerdown when mode==='over' && overFrom==='playing' → also startGame? It's friendly; but accidental click during death could restart unintentionally — actually that's fine and expected UX. Hmm, but then clicking anywhere... The overlay says "Press Space or Enter". I'll leave click NOT restarting (explicit per spec) — but wait: mobile users can't press Space easily! On touch devices, restart needs a tap. I'll add tap-to-restart for pointerType touch only? Simpler: allow click restart on game over too — harmless and improves mobile. But the CTA says press Space/Enter — add "or click" to the CTA text: "Press Space / Enter or click to restart". Wait, but then a stray click at death restarts instantly, and if the overlay hasn't appeared yet (650ms), an impatient click restarts — that's fine UX.
Hmm, but also careful: click during death from playing ALSO could be the pointerdown that... whatever, add it. Update CTA text: `Press <kbd>Space</kbd>/<kbd>Enter</kbd> or click to restart`. Actually then in pointerdown: `if(mode==='over'){startGame();return;}` — covers both demo & playing over. Simplify:
```js
document.addEventListener('pointerdown', (e) => {
if (mode === 'over') { startGame(); return; }
if (mode === 'demo') { startGame(); return; }
if (mode === 'paused') { resumeGame(); return; }
if (mode === 'playing') swipe = { x: e.clientX, y: e.clientY };
});
```
And keydown demo branch handles (mode==='over'&&overFrom==='demo') ✓ already.
Also update pause CTA: "Press P or click to resume" ✓ (click resumes via pointerdown ✓).
One concern: clicking to restart when mode==='over' but overlay not yet shown — yes works.
Now the footer hint "Space restart" fine.
Also should I worry that pointerdown fires for clicks on HUD (e.g., selecting text)? user-select none ✓.
Everything checks. Let me also give the canvas an aria-label and role for a11y nicety. Minor.
Final copy check for start card:
- kicker: "Classic arcade"
- title: "SNAKE"
- sub: "The snake is playing itself — jump in anytime."
- cta: "Click or press a key to start"
- keys: `<kbd>↑</kbd><kbd>↓</kbd><kbd>←</kbd><kbd>→</kbd> or <kbd>WASD</kbd> steer · <kbd>P</kbd> pause`
Hmm the CTA has a pulsing animation but no dot element — earlier CSS `.cta` includes gap for a possible dot; fine without.
Pause card:
- title sm: "PAUSED"
- sub: Press <kbd>P</kbd> or click to resume
Over card:
- title sm danger: GAME OVER
- final: Score 42px value
- newbest hidden
- sub: Session best: <b id=finalBest>
- cta: Press <kbd>Space</kbd> or <kbd>Enter</kbd> to restart
Now, let me think again about whether the "+10" pop and particles might be hidden behind the start overlay in demo — yes they will when near center; the overlay is translucent w/ blur — particles partially visible as colored glow. OK.
One more polish: make the demo overlay slightly smaller/offset? I'll keep centered — clean.
Also should the score chip show "Score" label + value; during demo the value updates as AI eats — nice liveliness.
Final check of the `hsl` body color function: k from 0(tail)→1(head segment next to head). Note i from n-1→1, k=1-i/(n-1): i=1 → k=1-1/(n-1) ≈ close to 1 for large n. Head drawn separately brighter ✓.
Small thing: the body stroke width lerp 0.58→0.70 makes the tail thinner than head — tapered snake ✓ nice.
Also add belly counters? no.
I believe the code is complete. Let me also scan for JS syntax slips as I write final: careful with template literals inside the IIFE — fine.
Also `newBestTag.hidden = !newRecord` — boolean ✓.
Let me now also double check the CSS class `.overlay` has border-radius 14 to match canvas — overlays are positioned inset 0 over stage; stage size equals canvas — ✓.
One thing: `#stage` cursor pointer only in demo ✓.
Also `body.demo` class initial in markup ✓.
Write out final answer now. Also ensure the script tag placed after DOM elements ✓.
Estimate: good. Also verify no stray `console.log` ✓.
One more possible runtime error: `document.getElementById('foot')` — id="foot" ✓ element exists.
`hud` id ✓, `stage` unused? I referenced stage only for... I removed usage; keep variable? I don't need stage in JS anymore (overlays sized via CSS inset 0). Remove `stage` const to avoid unused... harmless but keep code clean: remove.
In layout I set hud/foot widths; canvas sized; ✓.
Let me also double check `layout()` runs before `resetGame(true)` which pushes GO pop with cell — for demo none; player start later uses current cell ✓.
Also note: resetGame(true) → placeFood uses performance.now ✓.
Alright — also think: does anything reference `W`/`H` before layout? resetGame no. First frame after layout ✓.
Compose final HTML.
Actually one more check on the tongue draw: `bx = hx + dx*r*0.92` with r=0.44cell → 0.40cell forward — just at head edge (r=0.44) → slightly inside ✓; tip extends 0.42*ext cell beyond + fork 0.13 ✓.
Tongue drawn BEFORE eyes — fine (behind eyes? tongue is in front of head, eyes on head — order irrelevant, no overlap).
Also head drawn AFTER body so head overlaps first body segment ✓.
Eyes positions: side offset 0.185cell each side — with eye radius 0.115cell, eyes span from 0.07 to 0.30cell from center; head radius 0.44 ✓ inside.
Pupil forward offset 0.045cell inside eye (0.115) ✓.
Great. Also for tiny cells (10px): eye radius 1.15px, pupil 0.55px, glint 0.18px — glint tiny but fine.
lineWidth max(1.5, cell*0.09) head outline ✓.
Now, finally — the death flash redraw uses `through()` including pts[0] bumped — fine.
Also one more idea for polish: after death in playing mode, dim board? The overlay covers with rgba(5,9,7,.28)+blur card — ok.
Double-check overlay backdrop-filter over canvas — works in Chrome ✓.
Done. Write answer.
Let me write the complete final file text carefully.
Also, minor: in HTML I need `lang="en"`, meta charset ✓.
For the `h1` gradient text with letter-spacing and margin-right negative to balance ✓.
Final code below. Let me count elements: script ~340 lines. OK.
While writing, double check every `getElementById` string matches markup ids: game, hud, foot, scoreVal, bestVal, scoreChip, ovStart, ovPause, ovOver, ovOverTitle, finalScore, finalBest, newBestTag. ✓ all present in markup. demoChip only CSS.
One more sanity check of a tricky spot: `const s = (area >= body.length ? 1e6 + 500 - dist * 8 : area * 40 - dist * 8) + Math.random() * 3;` — if area big and dist 0 → ~1e6+500 vs alternative max 576*40=23040 — safe dominates ✓. If two safe options: differ by dist*8 → picks closer ✓. Random 3 breaks ties ✓.
Alright. Also small: in `aiDir`, if `bestD===null` (no safe moves) → returns null → step uses current dir → dies. ✓ (returns `dir` unchanged; keep going → collision → demo restart.) Fine.
Also I want the demo to look lively early — initial snake length 4, AI greedy → fine.
Also one more consideration: the AI blocked-set uses `body.length-1` (tail excluded) — but flood area comparison `area >= body.length` uses full length ✓ conservative enough.
Edge: at demo start snake len 4 — trivial.
OK writing final output now.
Wait — I should double check the CSS `.overlay` transition for visibility — `transition: opacity .35s ease, visibility .35s;` visibility animates as discrete — ok standard pattern.
Also `.card` transition transform ✓.
Also add `will-change`? no.
Also `#app` may overflow height if board exactly max — body overflow hidden hides bottom shadow; acceptable.
Let me finalize the size of `.fl-value` 42px — card fits.
Also on `matchMedia` reduced motion? skip.
Write it.
One more consideration about `IGNORE_KEY` regex and `^` anchor: codes like 'ShiftLeft' ✓ match; 'Space' no ✓; 'ArrowUp' no ✓; 'KeyP' no ✓. In demo mode pressing 'F5' → ignored → browser refresh allowed. fine.
In the demo branch I `e.preventDefault()` for accepted keys — prevents Space page scroll etc ✓.
Also for the playing branch: arrows preventDefault ✓, space preventDefault ✓, 'KeyP' preventDefault harmless.
Final answer time.
Also, remember: "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" ✓ demo board + snake + overlay.
Also the demo mode requires: "behind a 'Click or press a key to start' message" ✓ exact text in CTA: "Click or press a key to start" ✓.
Write the file.