Qwen3.8 Max (0902)
by Qwenreasoning defaultClean
Running live in a sandboxed frame. Click inside it before using the keyboard.
How it went
- Total time
- 12 min 51 s
- Cost
- $0.222
- Output tokens
- 36,836
- Spent thinking
- 25,652 tokens
- Speed
- 47.9 tok/s
- Console errors
- 0
- First token after
- 1.9 s
- First visible text after
- 9 min 52 s
- Prompt tokens
- 556
- Code size
- 28.0 KB
- Finish reason
- stop
- Served by
- Alibaba
- Model id
qwen/qwen3.8-max-0902- Reasoning sent
model default- Max output
- 131,072 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 · 722 lines
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>SNAKE · Terminal Garden</title>
<style>
:root{
--bg:#060d0a; --bezel1:#12241a; --bezel2:#0a150f;
--line:#274936; --panel:#0c1a12; --panel-line:#234531;
--lime:#a3e635; --lime-soft:#d9f99d; --mint:#5eead4; --green:#34d399;
--amber:#fbbf24; --red:#ff6b5e; --ink:#e9f6ea; --muted:#84a98f;
--mono:ui-monospace,"Cascadia Code","SF Mono",Menlo,Consolas,"Liberation Mono",monospace;
--sans:system-ui,-apple-system,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;
}
*{box-sizing:border-box}
html,body{margin:0}
body{
min-height:100vh; display:flex; align-items:center; justify-content:center;
font-family:var(--sans); color:var(--ink); overflow-x:hidden;
background:
radial-gradient(1100px 720px at 10% -12%, rgba(52,211,153,.12), transparent 62%),
radial-gradient(900px 640px at 106% 112%, rgba(251,191,36,.09), transparent 60%),
radial-gradient(760px 520px at 88% -18%, rgba(163,230,53,.07), transparent 60%),
var(--bg);
}
body::before{
content:""; position:fixed; inset:-44px; z-index:0; pointer-events:none;
background-image:
linear-gradient(rgba(163,230,53,.05) 1px, transparent 1px),
linear-gradient(90deg, rgba(163,230,53,.05) 1px, transparent 1px);
background-size:36px 36px;
-webkit-mask-image:radial-gradient(75% 75% at 50% 45%, #000 30%, transparent 100%);
mask-image:radial-gradient(75% 75% at 50% 45%, #000 30%, transparent 100%);
animation:gridDrift 70s linear infinite;
}
@keyframes gridDrift{to{background-position:36px 72px, 72px 36px}}
#bg{position:fixed; inset:0; width:100%; height:100%; z-index:0; pointer-events:none}
.frame{
position:relative; z-index:1; width:min(96vw,660px);
display:flex; flex-direction:column; gap:14px; padding:22px 0 26px;
}
.frame>*{animation:rise .6s cubic-bezier(.2,.7,.2,1) both}
.frame>*:nth-child(2){animation-delay:.08s}
.frame>*:nth-child(3){animation-delay:.16s}
@keyframes rise{from{opacity:0; transform:translateY(16px)}}
header{display:flex; justify-content:space-between; align-items:flex-end; gap:14px; flex-wrap:wrap}
.brand{display:flex; align-items:center; gap:13px}
.mark{flex:none; animation:bob 4.5s ease-in-out infinite}
@keyframes bob{0%,100%{transform:translateY(0)}50%{transform:translateY(-3px)}}
h1{
margin:0; font-family:var(--mono); font-weight:800;
font-size:clamp(30px,4.6vw,42px); letter-spacing:.14em; line-height:1;
color:var(--lime);
text-shadow:0 0 22px rgba(163,230,53,.45), 0 2px 0 rgba(0,0,0,.45);
}
.tag{
margin:6px 0 0; font-family:var(--mono); font-size:10px; font-weight:600;
letter-spacing:.34em; color:var(--muted); text-transform:uppercase;
}
.scores{display:flex; gap:10px}
.chip{
min-width:96px; padding:8px 16px 9px; text-align:center;
background:linear-gradient(180deg,#10231a,var(--panel));
border:1px solid var(--panel-line); border-radius:10px;
box-shadow:inset 0 1px 0 rgba(255,255,255,.05), 0 6px 18px -8px rgba(0,0,0,.7);
transition:transform .2s ease, box-shadow .2s ease;
}
.chip:hover{transform:translateY(-2px); box-shadow:inset 0 1px 0 rgba(255,255,255,.06), 0 10px 22px -8px rgba(0,0,0,.8)}
.chip .lbl{display:block; font-family:var(--mono); font-size:10px; font-weight:700; letter-spacing:.3em; color:var(--muted)}
.chip .val{
display:inline-block; margin-top:2px; font-family:var(--mono); font-weight:800;
font-size:clamp(20px,3.4vw,28px); line-height:1.1; font-variant-numeric:tabular-nums;
}
.val.lime{color:var(--lime); text-shadow:0 0 12px rgba(163,230,53,.4)}
.val.amber{color:var(--amber); text-shadow:0 0 12px rgba(251,191,36,.35)}
.val.pop{animation:pop .3s ease}
@keyframes pop{35%{transform:scale(1.3)}}
.chip.flash{animation:flash .9s ease}
@keyframes flash{
0%{box-shadow:0 0 0 0 rgba(251,191,36,.75), inset 0 1px 0 rgba(255,255,255,.05)}
100%{box-shadow:0 0 0 16px rgba(251,191,36,0), inset 0 1px 0 rgba(255,255,255,.05)}
}
.bezel{
position:relative; margin:0 auto; aspect-ratio:1;
width:min(100%, 620px, max(250px, calc(100vh - 218px)));
padding:10px; border-radius:14px;
background:linear-gradient(180deg,var(--bezel1),var(--bezel2));
border:1px solid var(--line);
box-shadow:0 24px 60px -22px rgba(0,0,0,.85), 0 0 46px -12px rgba(52,211,153,.28),
inset 0 1px 0 rgba(255,255,255,.05);
}
.bezel::after{
content:""; position:absolute; inset:10px; border-radius:8px; pointer-events:none; z-index:2;
background:repeating-linear-gradient(0deg, rgba(255,255,255,.024) 0 1px, transparent 1px 3px);
}
canvas#game{display:block; width:100%; height:100%; border-radius:8px; background:#0b1610; touch-action:none}
.overlay{
position:absolute; inset:10px; z-index:3; border-radius:8px;
display:flex; flex-direction:column; align-items:center; justify-content:center;
gap:12px; padding:20px; text-align:center; cursor:pointer;
background:rgba(4,10,7,.58); backdrop-filter:blur(3px);
transition:opacity .25s ease;
}
.overlay.hidden{opacity:0; pointer-events:none}
.ov-badge{
font-family:var(--mono); font-size:10px; font-weight:700; letter-spacing:.38em;
color:var(--mint); border:1px solid rgba(94,234,212,.45); border-radius:999px;
padding:6px 14px 6px 17px; background:rgba(94,234,212,.06);
}
.ov-title{
margin:0; font-family:var(--mono); font-weight:800;
font-size:clamp(28px,7vw,48px); letter-spacing:.16em; text-indent:.16em;
color:var(--lime); text-shadow:0 0 26px rgba(163,230,53,.5);
}
.ov-title.over{color:var(--red); text-shadow:0 0 26px rgba(255,107,94,.5)}
.ov-title.win{color:var(--amber); text-shadow:0 0 26px rgba(251,191,36,.5)}
.ov-score{
font-family:var(--mono); font-weight:800; font-size:clamp(34px,8vw,52px);
color:var(--amber); text-shadow:0 0 18px rgba(251,191,36,.45); line-height:1;
}
.ov-meta{font-family:var(--mono); font-size:11px; letter-spacing:.2em; color:var(--muted)}
.newbest{
display:inline-block; margin-left:8px; padding:3px 8px; border-radius:4px;
background:var(--amber); color:#161006; font-weight:800; letter-spacing:.14em;
animation:pop .4s ease;
}
.ov-prompt{
margin:4px 0 0; font-family:var(--mono); font-weight:700;
font-size:clamp(11px,2.6vw,13px); letter-spacing:.22em; color:var(--lime-soft);
animation:blink 1.3s ease-in-out infinite;
}
@keyframes blink{0%,100%{opacity:1}50%{opacity:.3}}
.ov-sub{margin:0; font-family:var(--mono); font-size:11px; letter-spacing:.14em; color:var(--muted)}
.ov-bestline{margin:0; font-family:var(--mono); font-size:11px; letter-spacing:.24em; color:var(--amber)}
.hints{
display:flex; flex-wrap:wrap; justify-content:center; align-items:center; gap:9px;
font-family:var(--mono); font-size:11px; letter-spacing:.08em; color:var(--muted);
}
.hints i{font-style:normal; opacity:.45}
kbd{
font-family:inherit; font-size:10px; padding:3px 7px; border-radius:6px;
background:#0d1c13; border:1px solid #2c523c; border-bottom-width:2px; color:#cfe9d2;
transition:transform .15s ease;
}
kbd:hover{transform:translateY(-1px)}
.touch-hint{display:none}
@media (pointer:coarse){.touch-hint{display:inline}}
#snd.dim{opacity:.55; text-decoration:line-through}
@media (max-width:480px){
.bezel{padding:8px}
.overlay{inset:8px}
.chip{min-width:80px; padding:6px 10px 7px}
.tag{letter-spacing:.22em}
}
@media (prefers-reduced-motion:reduce){
*{animation:none !important; transition:none !important}
}
</style>
</head>
<body>
<canvas id="bg"></canvas>
<div class="frame">
<header>
<div class="brand">
<svg class="mark" viewBox="0 0 96 40" width="56" height="24" aria-hidden="true">
<rect x="2" y="21" width="8" height="8" rx="2" fill="#3ea860"/>
<rect x="9" y="19" width="10" height="10" rx="2" fill="#4ecf6e"/>
<rect x="18" y="17" width="12" height="12" rx="3" fill="#63dd6f"/>
<rect x="29" y="14" width="14" height="14" rx="3" fill="#7fe96a"/>
<rect x="43" y="11" width="18" height="18" rx="5" fill="#a3e635"/>
<circle cx="54" cy="17" r="2.4" fill="#0b2417"/>
<path d="M62 20h6m0 0l3.5-2.5M68 20l3.5 2.5" stroke="#ff6b5e" stroke-width="2" stroke-linecap="round" fill="none"/>
<circle cx="83" cy="22" r="8" fill="#ff5148"/>
<circle cx="80.5" cy="19.5" r="2.4" fill="#ffd2c4" opacity=".8"/>
<path d="M83 14c1-3 3-4.5 5.5-4.5" stroke="#7ee06a" stroke-width="2.4" stroke-linecap="round" fill="none"/>
</svg>
<div>
<h1>SNAKE</h1>
<p class="tag">Terminal Garden · 24 × 24</p>
</div>
</div>
<div class="scores">
<div class="chip" id="chipScore"><span class="lbl">SCORE</span><span class="val lime" id="score">0</span></div>
<div class="chip" id="chipBest"><span class="lbl">BEST</span><span class="val amber" id="best">0</span></div>
</div>
</header>
<div class="bezel">
<canvas id="game" aria-label="Snake game board"></canvas>
<div class="overlay" id="overlay"></div>
</div>
<footer class="hints">
<span><kbd>↑↓←→</kbd> / <kbd>WASD</kbd> MOVE</span><i>·</i>
<span><kbd>P</kbd> PAUSE</span><i>·</i>
<span><kbd>SPACE</kbd> RESTART</span><i>·</i>
<span><kbd>M</kbd> <span id="snd">SOUND ON</span></span>
<span class="touch-hint"><i>·</i> SWIPE TO STEER</span>
</footer>
</div>
<script>
(() => {
'use strict';
/* ---------- constants & elements ---------- */
const GRID = 24, BASE_TICK = 132, MIN_TICK = 70;
const DIRS4 = [{x:1,y:0},{x:-1,y:0},{x:0,y:1},{x:0,y:-1}];
const RM = matchMedia('(prefers-reduced-motion: reduce)').matches;
const cvs = document.getElementById('game'), ctx = cvs.getContext('2d');
const bgCvs = document.getElementById('bg'), bctx = bgCvs.getContext('2d');
const overlay = document.getElementById('overlay');
const scoreEl = document.getElementById('score'), bestEl = document.getElementById('best');
const chipBest = document.getElementById('chipBest'), sndEl = document.getElementById('snd');
let dpr = 1, b = 0, cell = 0, bw = 0, bh = 0;
function fit(){
dpr = Math.min(2.5, window.devicePixelRatio || 1);
b = cvs.clientWidth || 300;
cvs.width = Math.max(1, Math.round(b * dpr));
cvs.height = Math.max(1, Math.round(b * dpr));
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
cell = b / GRID;
bw = innerWidth; bh = innerHeight;
bgCvs.width = Math.round(bw * dpr); bgCvs.height = Math.round(bh * dpr);
bctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
fit();
new ResizeObserver(fit).observe(cvs);
addEventListener('resize', fit);
/* ---------- state ---------- */
let state = 'demo'; // demo | demoDead | playing | paused | over | won
let snake = [], prevSnake = [], dir = {x:1,y:0}, queue = [], grow = 0;
let food = null, foodBorn = 0;
let score = 0, best = 0, newBestFlag = false;
let tickMs = BASE_TICK, acc = 0, renderT = 0, last = performance.now();
let particles = [], floaters = [], shake = 0, demoTimer = null, muted = false;
function reset(){
const cy = Math.floor(GRID/2), sx = Math.floor(GRID*0.30);
snake = [];
for(let i=0;i<4;i++) snake.push({x:sx-i, y:cy});
prevSnake = snake.map(p=>({x:p.x, y:p.y}));
dir = {x:1,y:0}; queue = []; grow = 0;
score = 0; tickMs = BASE_TICK; acc = 0; renderT = 0;
particles = []; floaters = []; shake = 0;
spawnFood();
setScoreChip();
}
function spawnFood(){
const occ = new Set(snake.map(s => s.x + ',' + s.y));
const free = [];
for(let y=0;y<GRID;y++) for(let x=0;x<GRID;x++){
if(!occ.has(x+','+y)) free.push({x,y});
}
if(!free.length){ food = null; return; }
food = free[(Math.random()*free.length)|0];
foodBorn = performance.now();
}
/* ---------- audio (tiny synth, created on first gesture) ---------- */
let actx = null;
function ensureAudio(){
if(!actx){ try{ actx = new (window.AudioContext || window.webkitAudioContext)(); }catch(e){} }
if(actx && actx.state === 'suspended') actx.resume();
}
function tone(f, d, type, g, delay, slide){
if(!actx || muted) return;
const t0 = actx.currentTime + (delay||0);
const o = actx.createOscillator(), ga = actx.createGain();
o.type = type||'square';
o.frequency.setValueAtTime(f, t0);
if(slide) o.frequency.exponentialRampToValueAtTime(Math.max(40, f+slide), t0+d);
ga.gain.setValueAtTime(0.0001, t0);
ga.gain.exponentialRampToValueAtTime(g||0.04, t0+0.012);
ga.gain.exponentialRampToValueAtTime(0.0001, t0+d);
o.connect(ga).connect(actx.destination);
o.start(t0); o.stop(t0+d+0.03);
}
const sEat = () => { tone(620,.08,'square',.05); tone(930,.11,'square',.05,.07); };
const sDie = () => { tone(300,.26,'sawtooth',.06,0,-220); tone(150,.3,'triangle',.05,.13,-90); };
const sStart = () => { tone(440,.07,'triangle',.05); tone(660,.1,'triangle',.05,.08); };
/* ---------- game logic ---------- */
function step(){
if(state === 'demo') dir = aiChoose();
else if(queue.length) dir = queue.shift();
const h = snake[0], nx = h.x + dir.x, ny = h.y + dir.y;
if(nx<0 || ny<0 || nx>=GRID || ny>=GRID){ die(nx,ny); return; }
const tailMoves = grow === 0;
for(let i=0;i<snake.length;i++){
if(tailMoves && i === snake.length-1) continue;
if(snake[i].x === nx && snake[i].y === ny){ die(nx,ny); return; }
}
prevSnake = snake.map(p => ({x:p.x, y:p.y}));
snake.unshift({x:nx, y:ny});
if(grow > 0) grow--; else snake.pop();
if(food && nx === food.x && ny === food.y) eat();
}
function eat(){
score += 10; grow += 1;
tickMs = Math.max(MIN_TICK, tickMs - 2.4);
burst((food.x+.5)*cell, (food.y+.5)*cell, ['#ffd76a','#a3e635','#fff7d6','#5eead4'], 22, .3);
floaters.push({x:(food.x+.5)*cell, y:(food.y+.5)*cell, t:0, dur:900, text:'+10'});
if(state === 'playing'){ setScoreChip(); sEat(); }
spawnFood();
if(!food){ // board completely filled
state = 'won';
if(score > best){ best = score; newBestFlag = true; bestEl.textContent = best; flashBest(); }
showOverlay('won');
}
}
function die(nx, ny){
const cx = ((nx != null ? nx : snake[0].x) + .5) * cell;
const cy = ((ny != null ? ny : snake[0].y) + .5) * cell;
burst(cx, cy, ['#ff6b5e','#ffb454','#ff8f7a','#ffe3c2'], 26, .36);
shake = RM ? 0 : 1;
if(state === 'demo'){
state = 'demoDead';
clearTimeout(demoTimer);
demoTimer = setTimeout(() => {
if(state === 'demoDead'){ reset(); state = 'demo'; showOverlay('demo'); }
}, 900);
} else {
state = 'over';
if(score > best){ best = score; newBestFlag = true; bestEl.textContent = best; flashBest(); }
showOverlay('over');
sDie();
}
}
function startGame(){
clearTimeout(demoTimer);
newBestFlag = false;
reset();
state = 'playing';
hideOverlay();
sStart();
last = performance.now();
}
/* ---------- demo AI (safe move toward food, prefers straight, avoids dead ends) ---------- */
function hitBody(x, y, tailMoves){
for(let i=0;i<snake.length;i++){
if(tailMoves && i === snake.length-1) continue;
if(snake[i].x === x && snake[i].y === y) return true;
}
return false;
}
function aiChoose(){
if(!food) return dir;
const h = snake[0], tailMoves = grow === 0;
const cands = [dir, {x:dir.y, y:-dir.x}, {x:-dir.y, y:dir.x}];
let bestD = dir, bestS = -1e9;
for(const d of cands){
const nx = h.x + d.x, ny = h.y + d.y;
if(nx<0 || ny<0 || nx>=GRID || ny>=GRID) continue;
if(hitBody(nx, ny, tailMoves)) continue;
let s = -(Math.abs(nx-food.x) + Math.abs(ny-food.y)) * 2;
let esc = 0;
for(const e of DIRS4){
const ex = nx + e.x, ey = ny + e.y;
if(ex<0 || ey<0 || ex>=GRID || ey>=GRID) continue;
if(ex === h.x && ey === h.y){ esc++; continue; } // head vacates its own cell
if(!hitBody(ex, ey, tailMoves)) esc++;
}
s += esc * 1.6;
if(esc <= 1) s -= 12;
if(d === dir) s += .7;
s += Math.random() * .9;
if(s > bestS){ bestS = s; bestD = d; }
}
return bestD;
}
/* ---------- input ---------- */
const KEYDIR = {
ArrowUp:[0,-1], KeyW:[0,-1], ArrowDown:[0,1], KeyS:[0,1],
ArrowLeft:[-1,0], KeyA:[-1,0], ArrowRight:[1,0], KeyD:[1,0]
};
function enqueue(x, y){
const lastD = queue.length ? queue[queue.length-1] : dir;
if(x === lastD.x && y === lastD.y) return; // duplicate
if(x === -lastD.x && y === -lastD.y) return; // would reverse
if(queue.length < 3) queue.push({x, y});
}
addEventListener('keydown', e => {
const c = e.code;
if(c === 'Space' || c === 'Enter' || c.indexOf('Arrow') === 0) e.preventDefault();
ensureAudio();
if(state === 'demo'){ startGame(); const kd = KEYDIR[c]; if(kd) enqueue(kd[0], kd[1]); return; }
if(state === 'demoDead') return;
if(state === 'over' || state === 'won'){
if((c === 'Space' || c === 'Enter') && !e.repeat) startGame();
return;
}
if(c === 'KeyP' && !e.repeat){
if(state === 'playing'){ state = 'paused'; showOverlay('paused'); }
else if(state === 'paused'){ state = 'playing'; hideOverlay(); }
return;
}
if(c === 'KeyM' && !e.repeat){
muted = !muted;
sndEl.textContent = muted ? 'SOUND OFF' : 'SOUND ON';
sndEl.classList.toggle('dim', muted);
return;
}
const kd = KEYDIR[c];
if(kd && state === 'playing') enqueue(kd[0], kd[1]);
});
overlay.addEventListener('click', () => {
ensureAudio();
if(state === 'demo' || state === 'over' || state === 'won') startGame();
else if(state === 'paused'){ state = 'playing'; hideOverlay(); }
});
let tS = null;
cvs.addEventListener('touchstart', e => {
ensureAudio();
if(state === 'demo' || state === 'over' || state === 'won'){ startGame(); e.preventDefault(); return; }
tS = {x:e.touches[0].clientX, y:e.touches[0].clientY};
}, {passive:false});
cvs.addEventListener('touchmove', e => {
if(!tS || state !== 'playing') return;
e.preventDefault();
const t = e.touches[0], dx = t.clientX - tS.x, dy = t.clientY - tS.y;
if(Math.abs(dx) < 22 && Math.abs(dy) < 22) return;
if(Math.abs(dx) > Math.abs(dy)) enqueue(Math.sign(dx), 0);
else enqueue(0, Math.sign(dy));
tS = {x:t.clientX, y:t.clientY};
}, {passive:false});
cvs.addEventListener('touchend', () => tS = null);
/* ---------- UI ---------- */
function setScoreChip(){ scoreEl.textContent = score; popEl(scoreEl); }
function popEl(el){ el.classList.remove('pop'); void el.offsetWidth; el.classList.add('pop'); }
function flashBest(){ chipBest.classList.remove('flash'); void chipBest.offsetWidth; chipBest.classList.add('flash'); }
function showOverlay(kind){
overlay.classList.remove('hidden');
if(kind === 'demo'){
overlay.innerHTML =
'<div class="ov-badge">◈ ATTRACT MODE ◈</div>' +
'<h2 class="ov-title">SNAKE</h2>' +
'<p class="ov-prompt">CLICK OR PRESS ANY KEY TO START</p>' +
'<p class="ov-sub">ARROW KEYS / WASD TO STEER · P PAUSES</p>' +
(best > 0 ? '<p class="ov-bestline">SESSION BEST — ' + best + '</p>' : '');
} else if(kind === 'over'){
overlay.innerHTML =
'<h2 class="ov-title over">GAME OVER</h2>' +
'<div class="ov-score">' + score + '</div>' +
'<div class="ov-meta">LENGTH ' + snake.length + ' · BEST ' + best +
(newBestFlag ? '<span class="newbest">NEW BEST!</span>' : '') + '</div>' +
'<p class="ov-prompt">SPACE / ENTER — PLAY AGAIN</p>';
} else if(kind === 'won'){
overlay.innerHTML =
'<h2 class="ov-title win">BOARD CLEARED!</h2>' +
'<div class="ov-score">' + score + '</div>' +
'<div class="ov-meta">PERFECT GAME · BEST ' + best +
(newBestFlag ? '<span class="newbest">NEW BEST!</span>' : '') + '</div>' +
'<p class="ov-prompt">SPACE / ENTER — PLAY AGAIN</p>';
} else { // paused
overlay.innerHTML =
'<h2 class="ov-title">PAUSED</h2>' +
'<p class="ov-prompt">PRESS P TO RESUME</p>';
}
}
function hideOverlay(){ overlay.classList.add('hidden'); }
/* ---------- particles & floaters ---------- */
function burst(x, y, cols, n, spd){
if(RM) n = Math.min(n, 8);
const sc = cell / 22;
for(let i=0;i<n;i++){
const a = Math.random() * Math.PI * 2;
const v = (0.05 + Math.random() * 0.15) * sc * (spd / 0.3);
particles.push({
x, y, vx:Math.cos(a)*v, vy:Math.sin(a)*v - 0.03*sc,
r:(1.2 + Math.random()*2.4) * sc, t:0, dur:420 + Math.random()*380,
col:cols[(Math.random()*cols.length)|0]
});
}
}
function updateFX(dt){
for(const p of particles){ p.t += dt; p.x += p.vx*dt; p.y += p.vy*dt; p.vy += 0.0007*dt; }
particles = particles.filter(p => p.t < p.dur);
for(const f of floaters) f.t += dt;
floaters = floaters.filter(f => f.t < f.dur);
shake = Math.max(0, shake - dt/260);
}
/* ---------- rendering ---------- */
function drawBoard(now){
ctx.clearRect(0, 0, b, b);
ctx.fillStyle = '#0a1710'; ctx.fillRect(0, 0, b, b);
ctx.fillStyle = 'rgba(163,230,53,.045)';
for(let y=0;y<GRID;y++) for(let x=0;x<GRID;x++)
if((x+y)&1) ctx.fillRect(x*cell, y*cell, cell, cell);
let rg = ctx.createRadialGradient(b/2, b/2, b*.08, b/2, b/2, b*.7);
rg.addColorStop(0, 'rgba(74,222,128,.07)'); rg.addColorStop(1, 'rgba(0,0,0,0)');
ctx.fillStyle = rg; ctx.fillRect(0, 0, b, b);
ctx.save();
if(shake > 0){
const m = shake * 7;
ctx.translate((Math.random()-.5)*m, (Math.random()-.5)*m);
}
if(food) drawFood(now);
drawSnake(now);
drawParticles();
drawFloaters();
ctx.restore();
let vg = ctx.createRadialGradient(b/2, b/2, b*.35, b/2, b/2, b*.74);
vg.addColorStop(0, 'rgba(0,0,0,0)'); vg.addColorStop(1, 'rgba(0,0,0,.32)');
ctx.fillStyle = vg; ctx.fillRect(0, 0, b, b);
ctx.strokeStyle = 'rgba(163,230,53,.16)'; ctx.lineWidth = 1.5;
ctx.strokeRect(.75, .75, b-1.5, b-1.5);
ctx.strokeStyle = 'rgba(163,230,53,.5)'; ctx.lineWidth = 2; ctx.lineCap = 'round';
const L = Math.max(10, b*.035), o = 3;
ctx.beginPath();
ctx.moveTo(o, o+L); ctx.lineTo(o, o); ctx.lineTo(o+L, o);
ctx.moveTo(b-o-L, o); ctx.lineTo(b-o, o); ctx.lineTo(b-o, o+L);
ctx.moveTo(b-o, b-o-L); ctx.lineTo(b-o, b-o); ctx.lineTo(b-o-L, b-o);
ctx.moveTo(o+L, b-o); ctx.lineTo(o, b-o); ctx.lineTo(o, b-o-L);
ctx.stroke();
}
function drawFood(now){
const pop = Math.min(1, (now - foodBorn) / 240);
const ease = 1 - Math.pow(1 - pop, 3);
const pulse = 1 + .07 * Math.sin(now / 170);
const cx = (food.x+.5)*cell, cy = (food.y+.5)*cell;
const r = cell * .34 * ease * pulse;
if(r <= 0) return;
ctx.fillStyle = 'rgba(0,0,0,.35)';
ctx.beginPath(); ctx.ellipse(cx, cy + r*.85, r*.9, r*.3, 0, 0, 7); ctx.fill();
ctx.save();
ctx.shadowColor = 'rgba(255,80,64,.55)'; ctx.shadowBlur = cell*.6;
const g = ctx.createRadialGradient(cx - r*.35, cy - r*.4, r*.15, cx, cy, r);
g.addColorStop(0, '#ff9d8a'); g.addColorStop(.55, '#ff5148'); g.addColorStop(1, '#c22626');
ctx.fillStyle = g;
ctx.beginPath(); ctx.arc(cx, cy, r, 0, 7); ctx.fill();
ctx.restore();
ctx.fillStyle = 'rgba(255,255,255,.75)';
ctx.beginPath(); ctx.ellipse(cx - r*.35, cy - r*.42, r*.22, r*.13, -0.6, 0, 7); ctx.fill();
ctx.strokeStyle = '#8a5a33'; ctx.lineWidth = Math.max(1.5, cell*.07); ctx.lineCap = 'round';
ctx.beginPath(); ctx.moveTo(cx, cy - r*.9);
ctx.quadraticCurveTo(cx + r*.1, cy - r*1.25, cx + r*.3, cy - r*1.35); ctx.stroke();
ctx.fillStyle = '#7ee06a';
ctx.beginPath(); ctx.ellipse(cx + r*.55, cy - r*1.25, r*.3, r*.14, -0.5, 0, 7); ctx.fill();
}
function drawSnake(now){
const t = renderT;
const pts = snake.map((s, i) => {
const p = prevSnake[i] || s;
return { x:(p.x + (s.x - p.x)*t + .5)*cell, y:(p.y + (s.y - p.y)*t + .5)*cell };
});
const d = dir;
const isDead = state === 'over' || state === 'demoDead';
ctx.lineJoin = 'round'; ctx.lineCap = 'round';
ctx.beginPath();
ctx.moveTo(pts[0].x, pts[0].y);
for(let i=1;i<pts.length;i++) ctx.lineTo(pts[i].x, pts[i].y);
ctx.save();
ctx.shadowColor = isDead ? 'rgba(255,107,94,.35)' : 'rgba(132,224,120,.45)';
ctx.shadowBlur = cell*.55;
ctx.strokeStyle = isDead ? '#4c2a22' : '#0d3a24';
ctx.lineWidth = cell*.8;
ctx.stroke();
ctx.restore();
const hp = pts[0], tp = pts[pts.length-1];
const lg = ctx.createLinearGradient(hp.x, hp.y, tp.x, tp.y);
if(isDead){ lg.addColorStop(0,'#8f9a6a'); lg.addColorStop(.5,'#66754f'); lg.addColorStop(1,'#46543f'); }
else{ lg.addColorStop(0,'#9ef04f'); lg.addColorStop(.5,'#3ecf74'); lg.addColorStop(1,'#149e69'); }
ctx.strokeStyle = lg; ctx.lineWidth = cell*.62; ctx.stroke();
ctx.strokeStyle = 'rgba(8,38,22,.22)'; ctx.lineWidth = cell*.18; ctx.stroke();
const pxv = -d.y, pyv = d.x;
const hr = cell*.5;
if(!isDead){
const cyc = now % 2300;
if(cyc < 230){
ctx.strokeStyle = '#ff5d7a'; ctx.lineWidth = Math.max(1.5, cell*.07); ctx.lineCap = 'round';
const sx = hp.x + d.x*hr*.95, sy = hp.y + d.y*hr*.95;
const ex = sx + d.x*cell*.5, ey = sy + d.y*cell*.5;
ctx.beginPath();
ctx.moveTo(sx, sy); ctx.lineTo(ex, ey);
ctx.lineTo(ex + (d.x*.5 + pxv*.9)*cell*.14, ey + (d.y*.5 + pyv*.9)*cell*.14);
ctx.moveTo(ex, ey);
ctx.lineTo(ex + (d.x*.5 - pxv*.9)*cell*.14, ey + (d.y*.5 - pyv*.9)*cell*.14);
ctx.stroke();
}
}
const hg = ctx.createRadialGradient(hp.x - d.x*hr*.3, hp.y - d.y*hr*.3, hr*.1, hp.x, hp.y, hr);
if(isDead){ hg.addColorStop(0,'#c9b98a'); hg.addColorStop(1,'#7d8a5c'); }
else{ hg.addColorStop(0,'#e9ffb0'); hg.addColorStop(.55,'#a8e856'); hg.addColorStop(1,'#57c95e'); }
ctx.fillStyle = hg;
ctx.beginPath(); ctx.arc(hp.x, hp.y, hr, 0, 7); ctx.fill();
const eo = cell*.19, fo = cell*.13, er = cell*.16;
for(const sgn of [1, -1]){
const ex = hp.x + d.x*fo + pxv*eo*sgn;
const ey = hp.y + d.y*fo + pyv*eo*sgn;
ctx.fillStyle = isDead ? '#dfe8d8' : '#f6fff1';
ctx.beginPath(); ctx.arc(ex, ey, er, 0, 7); ctx.fill();
if(isDead){
ctx.strokeStyle = '#1c2f22'; ctx.lineWidth = Math.max(1.6, cell*.06); ctx.lineCap = 'round';
ctx.beginPath();
ctx.moveTo(ex - er*.65, ey - er*.65); ctx.lineTo(ex + er*.65, ey + er*.65);
ctx.moveTo(ex + er*.65, ey - er*.65); ctx.lineTo(ex - er*.65, ey + er*.65);
ctx.stroke();
} else {
ctx.fillStyle = '#0d2318';
ctx.beginPath(); ctx.arc(ex + d.x*er*.42, ey + d.y*er*.42, er*.52, 0, 7); ctx.fill();
ctx.fillStyle = 'rgba(255,255,255,.9)';
ctx.beginPath(); ctx.arc(ex + d.x*er*.15 - er*.2, ey + d.y*er*.15 - er*.25, er*.17, 0, 7); ctx.fill();
}
}
}
function drawParticles(){
ctx.save();
ctx.globalCompositeOperation = 'lighter';
for(const p of particles){
const a = Math.max(0, 1 - p.t/p.dur);
ctx.globalAlpha = a;
ctx.fillStyle = p.col;
ctx.beginPath(); ctx.arc(p.x, p.y, p.r*(.5 + a*.5), 0, 7); ctx.fill();
}
ctx.restore();
ctx.globalAlpha = 1;
}
function drawFloaters(){
ctx.textAlign = 'center';
ctx.font = '800 ' + Math.round(Math.max(12, cell*.48)) + 'px ui-monospace,Menlo,Consolas,monospace';
for(const f of floaters){
const k = f.t / f.dur;
const a = k < .6 ? 1 : 1 - (k - .6) / .4;
const y = f.y - cell * (.3 + 1.3 * (1 - Math.pow(1 - k, 2)));
ctx.globalAlpha = Math.max(0, a);
ctx.lineWidth = 3; ctx.strokeStyle = 'rgba(0,0,0,.55)';
ctx.strokeText(f.text, f.x, y);
ctx.fillStyle = '#ffd76a';
ctx.fillText(f.text, f.x, y);
}
ctx.globalAlpha = 1;
}
/* ---------- ambient fireflies ---------- */
const flies = [];
for(let i=0;i<26;i++) flies.push({
x:Math.random(), y:Math.random(),
r:.8 + Math.random()*2.4, s:.006 + Math.random()*.014,
ph:Math.random()*6.283, warm:Math.random() < .3
});
function drawFlies(now, dt){
bctx.clearRect(0, 0, bw, bh);
if(RM) return;
bctx.save();
bctx.globalCompositeOperation = 'lighter';
for(const f of flies){
f.y -= f.s * dt / 1000;
f.x += Math.sin(now/1500 + f.ph) * .00004 * dt;
if(f.y < -.04){ f.y = 1.04; f.x = Math.random(); }
const a = .10 + .12 * (.5 + .5 * Math.sin(now/900 + f.ph));
bctx.fillStyle = f.warm ? 'rgba(251,191,36,' + a + ')' : 'rgba(134,239,172,' + a + ')';
bctx.beginPath(); bctx.arc(f.x*bw, f.y*bh, f.r, 0, 7); bctx.fill();
}
bctx.restore();
}
/* ---------- main loop ---------- */
function frame(now){
const dt = Math.min(100, now - last); last = now;
while(acc >= tickMs && (state === 'demo' || state === 'playing')){
acc -= tickMs;
step();
}
if(state === 'demo' || state === 'playing') renderT = Math.min(1, acc / tickMs);
updateFX(dt);
drawBoard(now);
drawFlies(now, dt);
requestAnimationFrame(frame);
}
/* ---------- boot: attract mode ---------- */
reset();
state = 'demo';
showOverlay('demo');
requestAnimationFrame(frame);
})();
</script>
</body>
</html>Reasoning trace (25,652 tokens)
The user is asking for a polished Snake game in a single HTML file using vanilla JavaScript and canvas. Requirements:
- Grid of at least 20×20 cells, centered on the page.
- Arrow keys and WASD control the snake. Consecutive rapid key presses must both be honored (input queue), but the snake can never reverse into itself.
- Eating food gives +10 points, and the snake grows by one cell. New food never spawns on the snake.
- Collision with a wall or self causes game over, with an overlay showing score. Space or Enter to restart, P to pause.
- Visual polish: smooth motion, a snake with a distinct head and eyes oriented in the direction of travel, small particle burst when eating.
- Display current score and session best score.
Plus the system output rules: self-contained single file, inline CSS/JS, no storage APIs, must look good at 1280×800 and adapt down to 375px, no horizontal scroll, something meaningful on first paint, and the game needs to begin in a self-playing demo mode behind a "Click or press a key to start" message (a simple AI plays), so the game can be evaluated in motion without being played. This is important: the game has to auto-play with an AI behind a "Click or press a key to start" overlay.
Design intuition: I want a lively, layered background, motion, micro-interactions, strong typographic contrast. Start with what's distinctive about the subject — for a snake game, the game itself front and center, arcade cabinet vibe. Pair a distinctive display typeface with a legible body typeface. External fonts are out (no network requests) — "except for CDN URLs the task explicitly permits" — but the task doesn't permit any CDN, so system fonts only. I can use font stacks: something distinctive like a monospace stack for display ("ui-monospace, 'Cascadia Code', 'JetBrains Mono', Menlo, Consolas, monospace") or a chunky system stack. For that arcade feel, monospace works well and gives it character. Pair with a rounded/heavier body. Actually, a nice pairing: heavy monospace for display headers/scoreboard, clean sans for body.
Palette: Snake games tend toward dark + green. A dark background but layered richness: a deep forest/teal ambience with multiple accent colors (snake greens, apple-red food, warm gold for score). Multiple hues keep it from feeling flat. Or retro CRT-style. Let's go with a "deep-jungle arcade" theme: very dark blue-green background with ambient scanlines/vignette, board with subtle checkered cells, snake as gradient green with a cream belly? Food as glossy apple red. Particles gold/green.
Page layout: centered column: header with title (distinctive display treatment, maybe stacked letterforms), scoreboard chips for SCORE and BEST, canvas, footer with key hints. I want the game itself front and center with a scoreboard. The game itself is the distinctive opening. Maybe add subtle ambient background: layered radial gradients, faint grid pattern, drifting particles? An ambient canvas background could be nice but keep performance reasonable. Could do CSS-only ambient: two soft radial glows slowly pulsing, plus a fine grid texture. Keep it elegant.
Smooth motion: classic approach — tick-based logic, but interpolate render positions between previous grid position and current grid position. So maintain snake segments with previous cell and current cell, and lerp with t = timeSinceTick / tickInterval. Head eyes face direction: draw eyes offset toward direction vector.
Input queue: keep a queue of up to 2–3 direction inputs, applied one per tick. Never allow reversing against the currently applied direction (compare to the last queued direction / current direction).
Food spawn: pick a random empty cell from free cells (build list of free cells) to guarantee it's not on the snake.
Particles: when eaten, spawn ~18 particles with velocity, slight gravity, fade; drawn to canvas. Plus a floating "+10" text popup.
Pause: toggle pause with P, overlay "PAUSED". Space/Enter to restart on game over. Space/Enter or click to start from attract mode.
Demo mode (attract): simple AI plays. AI: pick a safe move (not wall, not body), prefer moves that reduce Manhattan distance to food; avoid immediate death; tie-break somewhat randomly. Must not reverse. Run the game in demo mode behind an overlay message "CLICK OR PRESS ANY KEY TO START". When user interacts, reset and start fresh game with control. Game over during demo → auto-restart demo after short delay.
Game-over overlay: dim the board, show "GAME OVER", final score, best, "Press SPACE or ENTER to restart". In demo mode, overlay says "SNAKE — Click or press a key to start" with control hints, while AI plays behind.
Score display: DOM elements updated, with a pop animation on change (scale bump). Best score in session only (no localStorage) — hold in JS variable. Maybe show "NEW BEST!" flourish.
Additional polish:
- Board rendered with rounded cells, soft shadow/glow on snake.
- Snake drawn as a smooth rounded path: draw each segment as a circle/rounded rect connected; better: a path along the interpolated points with round lineJoin and variable width — draw a thick polyline through interpolated segment centers, with lineCap round. That gives smooth snake body. Then draw a bigger head circle and eyes.
- Subtle checkerboard: two close dark greens for board cells.
- Food: apple — red circle with radial highlight, tiny leaf, gentle pulse animation.
- Screen shake on game over (small).
- Vignette + scanlines overlay for CRT flavor? Could be elegant: subtle scanlines at low opacity. Might skip heavy CRT. Layered glow is enough.
- Sound? Not required; could add WebAudio blips — adds life. A tiny beep on eat, a thud on game over, using WebAudio oscillators. Must avoid errors; create AudioContext on first user gesture. Demo mode shouldn't make sound (no gesture anyway — AudioContext would be suspended). Add sounds only after user starts. That's a nice polish. Keep volume low. Add a mute toggle? Could add "M" for mute; add to hints. Keep simple but include.
Sizing/responsive: canvas should fit viewport. Use a logical grid of 24×24 cells (≥20). Cell size computed from container size; use devicePixelRatio scaling for crispness. Resize handler. Board area: min(72vh, 92vw) or so. Frame with header on top, hints below.
Speed: start tick at ~130ms, slightly speed up as score increases (min ~70ms). Smooth interpolation uses tick interval.
Growth: when eaten, grow by one (add a tail that doesn't move for one tick). Implementation: snake as array of {x,y}; each tick compute new head; if eaten, don't pop tail, increment pendingGrowth... Actually simplest: if eaten, growBy = 1 → skip tail pop for one tick. Since requirement: grows by one cell.
Self-collision: check new head against body (excluding tail if tail will move; classic). Exclude tail cell when not growing.
Walls: collision = death (requirement). No wrap.
Demo AI: at each tick, from the current direction, compute possible turns (straight, left, right — never reverse). For each candidate, compute next cell; reject if wall or body (consider tail moving). Score candidates: prefer decreasing distance to food; add small penalty to avoid moves that create dead ends? Keep simple: safety + distance + slight randomness. To make it look decent, also avoid moves where the resulting head has zero safe neighbors (lookahead 1). Should be fine.
Overlay: DOM div positioned over canvas with backdrop blur? Avoid glassmorphism everywhere; a single overlay with slight blur is fine but I'd rather do a translucent dark overlay with a chunky bordered panel — arcade-card style. Use uppercase display font, letter-spacing.
Typography: display = system stack that feels distinctive. Options: "Impact, 'Arial Narrow Bold', sans-serif" gives an arcade-poster vibe; or monospace. I'd pair a chunky display (Impact/Haettenschweiler) with a legible body (system-ui). Hmm, Impact can look meme-y. Alternative: use monospace for display, with heavy weight and letter-spacing, gives retro-terminal-snake character: `font-family: "Cascadia Mono", "JetBrains Mono", ui-monospace, "SF Mono", Menlo, Consolas, monospace;` bold. Body: system-ui. That's a distinctive pairing that's safe offline. Snake is literally from terminal era — monospace display fits perfectly. Make title "SNAKE" with green glow, and maybe a decorative ▓▓ snake glyph in CSS/unicode? Could draw a tiny snake icon in a mini-canvas or use unicode "◆". Keep classy: small pixel snake icon made from CSS box-shadow? Simpler: inline SVG pixel snake next to title. Inline SVG allowed (no external).
Page background: layered — base deep color (#0b1410?), teal/green radial glows in corners, faint repeating grid lines (like a garden). Slow drifting animation on the glows via keyframes (transform translate). Also floating "firefly" particles? Can do cheaply in JS on the main canvas behind the board? The main canvas is just the board. Could add a second fullscreen canvas for ambient fireflies — nice touch for a lively background, low cost (~24 particles). But the board canvas is centered; fireflies behind add depth. Let's add a background canvas fixed fullscreen drawing soft drifting spores. Low alpha. Pause when tab hidden (requestAnimationFrame handles that automatically).
DOM structure:
```
<canvas id="bg">
<main class="frame">
<header>
<div class="brand"> svg pixel snake + h1 SNAKE + tagline </div>
<div class="scores"> chip SCORE 0000, chip BEST 0000 </div>
</header>
<div class="board-wrap">
<canvas id="game"></canvas>
<div id="overlay"> ... </div>
</div>
<footer class="hints"> key hints: arrows/WASD move · P pause · SPACE restart · M sound </footer>
</main>
```
Keyboard: preventDefault on arrows/space to avoid scroll. Listen on window; but iframe needs click to receive keys — overlay says click or press a key. First keydown/click starts the game.
State machine: 'demo' | 'playing' | 'paused' | 'gameover'. Demo: AI controls, overlay shows start prompt (semi-transparent, board still visible). On start: full reset, state playing, hide overlay. Pause: show PAUSED overlay. Game over: show overlay with score, best, restart prompt; demo never shows game-over overlay (auto-restart after 900ms with a short flash).
Best score: update when a run ends with score > best; mark "NEW BEST" in overlay; flash the best chip.
Score pop animation: add class on update, remove after animation.
Rendering details:
- Board: fill background with dark; subtle alternating cells; inner border glow.
- Snake: interpolate positions between ticks. Store prevSegments and segments. On tick, copy current to prev (handle growth: new tail segment's prev = same as tail's prev? For a new tail that didn't move, its prev = itself → interpolation fine). Compute a point for each segment i: lerp(prev[i], cur[i], t). If lengths differ (growth), use same position for extra prev point as cur (no motion). Points are in cell coords; convert to px: (x + 0.5) * cell.
- Draw body: stroke a polyline through points with lineWidth = cell*0.72, lineCap/join round, gradient along snake? Can use a linear gradient from head to tail approx by making gradient between head point and tail point. Simpler: draw body with base color then overlay per-segment circles with hue shift? A gradient stroke between head and tail points works decently. Plus subtle darker outline: stroke wider in darker color, then brighter gradient inside — gives a nice rounded look. Then belly highlight: a thin brighter line with offset? Maybe a small scale effect: draw small circles at joints? Keep clean.
- Head: circle slightly bigger than body, with eyes: two white circles with dark pupil offset toward direction vector; maybe a small tongue flicker just before eating? Tongue: occasionally (timer) draw a flickering red line forward. Cute detail — implement a timer: flicker every ~1.6s for 200ms.
- Food: pulse scale via sin(time), radial gradient red, highlight, leaf (small green oval), shadow.
- Particles: array of {x,y,vx,vy,life,color,size}; update with dt; draw as glowing circles with globalAlpha fade. On eat, spawn 16–22 particles at food position with mixed colors (gold, lime, white).
- Also a floating "+10" text that rises and fades.
- Screen shake: shakeT timer; on game over, offset canvas transform by a few px of random for ~250ms.
- Death animation: flash the snake to red/white? Could blink head. Simple: on death, draw an expanding ring at head + particles in red. Nice.
Grid: 24×24? Requirement ≥20×20. Use 22 or 24. Let's go 24.
Tick speed: base 132ms, decreasing 2ms per food eaten down to 72ms? Demo same speed.
Interpolation t = min(1, elapsed/tickInterval). Use accumulator: acc += dt; while acc >= interval → step. Then t = acc/interval.
Input queue: on keydown, map to direction; push if different from last queued direction AND not opposite to last queued/current. Queue max 3. On each step, if queue non-empty, shift and apply (apply only if not opposite to current — already enforced). This honors consecutive rapid presses (e.g., quick up→left becomes two turns on consecutive ticks).
Demo AI move selection: candidates = [current dir, left of current, right of current]. Filter safe (in bounds, not colliding with body excluding moving tail). Score: distance to food (Manhattan) with negative weight; prefer straight (small bonus); avoid cells that after moving have fewer escape options (count safe neighbors) with weight. Pick best with small random jitter. If none safe → keep current direction (die, then restart demo).
Audio: small synth: function beep(freq, dur, type, gain). On eat: quick two-note (660→880) square wave, low gain. On death: descending saw. On start: blip. Only when state playing/paused etc. AudioContext created/resumed lazily on first user gesture. Mute toggle on M; show a small speaker indicator in footer? Put a hint chip "M — sound on/off" and toggle text? Keep hint static, and small dot indicator. Simpler: just the mute variable; mention in footer. That's fine.
Best chip flash "NEW BEST!" — overlay shows a badge.
Edge: When starting from demo via click/keydown, ensure the key that started isn't also interpreted as a direction that would cause immediate weirdness — actually, allowing it is fine (e.g., pressing ArrowUp to start also sets initial queue direction? The snake starts moving right; pressing up is a valid turn). But if user presses Enter to start, nothing directional. Handle: keydown during demo → startGame, and if key is a direction key, also enqueue it. Good.
Prevent P pause during demo — ignore.
Space also on game over restarts; Enter too.
Also board click for start (during demo); on game over click also restart? Sure — allow click/tap to restart. Nice for mobile. Also add touch swipe controls? "adapt gracefully to 375px" — mobile support via swipe would be great polish. Implement touchstart/touchmove to detect swipe direction, enqueue. On-screen D-pad? Swipe is simpler and cleaner; mention "swipe" hint on small screens. Let's implement swipe.
Canvas sizing: board-wrap uses CSS aspect-ratio 1/1, width min(92vw, 62vh, 560px)? Compute: 1280×800 viewport has enough height. Use CSS: `.board-wrap { width: min(92vw, calc(100vh - 230px), 600px); aspect-ratio: 1; }` then JS reads clientWidth, sets canvas width/height with dpr scaling, cell = size/GRID.
Colors:
- Page bg: #07100d base with glows #123528 / #0d2b22, plus a warm amber accent in opposite corner for depth (multi-hue).
- Board bg: #0e1b14; alternating cells #10201880 slightly brighter; border: 2px #2c5a41 + outer glow.
- Snake gradient: head #a8f04a → mid #4ecf5e → tail #1f9e63? Lime-to-teal-green.
- Eyes: white #f4fff0 with pupil #10231a; maybe tiny green apple eye? Keep dark pupil.
- Food: body #ff5148, highlight #ffd2c4, leaf #7ee06a, shadow.
- Particles: #ffd76a, #a8f04a, #fff.
- Score chips: dark green cards with lime numbers (tabular monospace), label small letter-spaced uppercase.
- Title: lime with glow; maybe "SNAKE" letters have slight vertical jitter? Keep legible: bold monospace, letter-spaced, green with glow text-shadow; plus small pixel snake SVG.
Header layout: brand left, scores right — flex, wraps on mobile. Footer hints: row of kbd-style keys.
Overlay design: absolute inset, display flex center; content: big display title, subtitle, pulsing prompt. Demo overlay: slightly dark (rgba(4,10,8,.55)), prompt "CLICK OR PRESS ANY KEY TO START" with blinking caret or pulse; show controls briefly too. Game over overlay darker, "GAME OVER" in red-tinged lime? Use amber/red accent: #ffb454? Actually game-over title color #ff6b5e (matches food red), score large. Add small stats: LENGTH, BEST. Restart prompt pulses.
Pause overlay: "PAUSED — press P to resume".
Also add subtle CSS micro-interactions: chip hovers lift, kbd hover, title glow pulse animation, board entrance animation (scale/fade on load). Score bump keyframe.
Fireflies on background canvas: ~26 spores drifting slowly upward, alpha 0.15–0.35, green/amber mix, wrap. Slow.
Reduced motion? Can respect prefers-reduced-motion to disable fireflies/shake. Nice touch: check matchMedia once, skip ambient animation. Keep code simple: if reduced, skip fireflies and screen shake.
Now the tick step function:
```
function step(){
if(queue.length) dir = queue.shift();
const head = snake[0];
const nx = head.x + dir.x, ny = head.y + dir.y;
// wall
if(nx<0||ny<0||nx>=GRID||ny>=GRID){ die(); return; }
// self: will tail move? grows>0 means tail stays
const willGrow = pendingGrows>0; // from previous eat? Actually eat happens after move.
...
}
```
Growth mechanics: when eaten, set `grow += 1`. On each step: compute new head; if collides → die; else unshift head; if grow>0 { grow--; } else snake.pop(). Also maintain prevPositions array for interpolation, parallel to snake: prevSnake = positions before step. When length grows, the new tail index didn't exist → prev uses same as cur (interpolation neutral). Implementation: before step, prevSnake = snake.map(s=>({x,y})). After step, snake has same length (tail popped) or +1 (grow). For interpolation of segment i: cur = snake[i]; prev = prevSnake[i] ?? snake[i]. Note: if growing, the last segment is the old tail not popped → prevSnake[i] exists (equal to cur, since tail didn't move). Good. If not growing, lengths equal. Head always has prev.
Wait: when growing, snake[1..] = old snake[0..n-2]; prevSnake[i] = old snake[i]. For segment i=1: cur=old head position, prev=old snake[1] position → interpolate from old position to previous head: correct, body follows head. For last segment i=n: cur=old snake[n-1] (old tail), prevSnake[n-1]=old tail → same, no motion.
Food collision: after unshifting head, if head at food position → score += 10, grow += 1, spawnFood(), particles, sound, maybe speed up.
Death: state='gameover' (if playing): record best, show overlay after slight delay? Show instantly with shake + particles on head. Freeze interpolation: fix t (save final t and keep drawing). Simpler: on death set render t=1, prevSnake = snake copy → static. Add death ring particles.
Food spawn: build free cell list each spawn (GRID² up to 576, fine). If none → win? Handle: if free.length===0 → win state (shows YOU WIN). Rare; include for correctness.
Demo AI step: choose dir before normal step. Implement chooseDir():
```
function chooseDir(){
const head=snake[0];
const dirs=[dir, turnLeft(dir), turnRight(dir)];
let best=null, bestScore=-1e9;
for(const d of dirs){
const nx=head.x+d.x, ny=head.y+d.y;
if(out of bounds) continue;
if(cellOccupied(nx,ny, tailMoves)) continue;
let s = -manhattan(nx,ny,food)*2;
// look-ahead escape
const escapes = countSafeNeighbors(nx,ny);
s += escapes*1.5;
if(d===dir) s += 0.4; // prefer straight
s += Math.random()*0.8;
if(s>bestScore){bestScore=s;best=d;}
}
return best || dir;
}
```
cellOccupied excludes the tail cell that will move away (if not growing). In demo grow is rare; handle approximately: exclude last segment unless grow>0.
countSafeNeighbors: count of the 4 neighbors that are in-bounds and not occupied by body (again excluding moving tail; also excluding head's new position itself? Count neighbors of the new head cell that are not occupied by snake body after moving there — approximate using current body minus tail, and include the new head position as occupied). Fine as approximation.
Rendering loop always runs (rAF). Each frame: update acc/ticks only when state is demo/playing (not paused/gameover). Particles and pulse timer update always with dt.
Draw order: board cells → food shadow/food → snake body → head/eyes → particles → floating text → vignette? Plus food glow.
Also draw a subtle inner border highlight.
Head direction for eyes: use current dir (the applied one). Interpolate eye orientation on turn? Keep dir-based; fine.
Eyes: head center hc; direction vector d (normalized from interpolated motion? Use dir). eyeOffset perpendicular p = (-d.y, d.x). Eye centers = hc + d*0.16*cell ± p*0.18*cell, radius 0.16cell white; pupil = eye center + d*0.05cell, radius 0.08cell dark. Maybe small green apple eye? Keep dark pupil. Cute.
Tongue: when flickering, draw a line from mouth (hc + d*0.45cell) extending d*0.35cell with a fork. Red #ff5d7a.
Death flash: alternate snake fill to red for a few frames? Set deadFlash timer and tint the stroke red at intervals. Simple: on death, draw the snake normally but draw a big red ring burst; plus shake. Enough, maybe head turns X-eye? Fun: on game over, draw X eyes instead of pupils. Let's do that! dead state → draw X eyes.
Overlay DOM updates: function showOverlay(kind) constructs innerHTML per kind. Keep three templates. Prompt blinking via CSS animation.
Score chip DOM updates with pop class re-trigger: el.classList.remove('pop'); void el.offsetWidth; el.classList.add('pop').
Now sizing calculation for 1280×800: header ~90px, footer ~60px, board min(calc(100vh - 210px), 92vw, 560px) → 560px board fits nicely. At 375px: board = min(92vw=345, ...) fine; header stacks.
Font sizes: title clamp(28px, 5vw, 44px). Score numbers large monospace.
Let me write the code carefully now.
CSS variables:
```
:root{
--bg:#060d0a; --panel:#0d1a13; --line:#1e3a2b;
--lime:#a3e635; --lime-soft:#d9f99d; --green:#34d399;
--amber:#fbbf24; --red:#ff6b5e; --ink:#e7f5e9; --muted:#7fa38c;
}
```
Hmm, lime is Tailwind-y but fine. Multi-hue: lime for snake/score, amber for best/accents, red for food/game over, teal for ambient glow.
Background layers: body::before with radial gradients (top-left teal, bottom-right amber, very dark), plus faint grid via low-opacity repeating-linear-gradient, plus a vignette. Fireflies canvas behind content (z-index 0, fixed). Add slow hue drift? Keep: fireflies + gradients is enough.
Board frame: .board-wrap with padding? Canvas itself with border and glow box-shadow, plus an outer "bezel": wrapper with background gradient dark and padding 10px, border-radius 14px, inner shadow — arcade bezel feel. Nice: bezel with subtle conic border sheen. Modest radii — bezel 14px, chips 10px, more squarish for the kbd.
Corner accents: little screw dots on bezel? Cute but maybe skip.
Scanline overlay on board: .board-wrap::after with repeating-linear-gradient rgba lines at 4% opacity, pointer-events none. Subtle CRT. ok.
Now JS structure:
```
const GRID=24;
const canvas=..., ctx=...
let dpr, cellPx, boardPx;
resize(): boardPx = wrap.clientWidth; canvas.width=boardPx*dpr; ctx.setTransform(dpr,0,0,dpr,0,0); cellPx=boardPx/GRID;
```
Wrap width via CSS; use ResizeObserver or window resize listener. window resize + read wrap.clientWidth.
State variables:
```
let state='demo'; // demo | playing | paused | over | won
let snake, prevSnake, dir, queue, grow, food, score, best=0, tickMs, acc=0, last=performance.now();
let particles=[], floaters=[], shake=0, dead=false, deadAt=null;
let tongueTimer=0;
```
reset(forDemo): place snake horizontally centered-left: [{8,12},{7,12},{6,12},{5,12}] head first, moving right, dir={1,0}. score=0 (update chips), tickMs=BASE, queue=[], grow=0, spawnFood, particles=[], floaters=[].
startGame(): reset(); state='playing'; hideOverlay(); sound start; acc=0.
Demo restarts similarly but state='demo', showOverlay('demo').
Keyboard handler:
```
const KEYDIRS={ArrowUp:[0,-1],KeyW:[0,-1],ArrowDown:[0,1],KeyS:[0,1],ArrowLeft:[-1,0],KeyA:[-1,0],ArrowRight:[1,0],KeyD:[1,0]};
window.addEventListener('keydown', e=>{
if(['ArrowUp','ArrowDown','ArrowLeft','ArrowRight','Space'].includes(e.code)) e.preventDefault();
ensureAudio();
if(state==='demo'){ startGame(); const kd=KEYDIRS[e.code]; if(kd) enqueue(kd); return; }
if(state==='over'||state==='won'){ if(e.code==='Space'||e.code==='Enter'){ startGame(); } return; }
if(e.code==='KeyP'){ if(state==='playing'){state='paused';showOverlay('paused');} else if(state==='paused'){state='playing';hideOverlay(); last=performance.now(); acc=0? } return;}
```
Careful with pause/resume timing: use dt clamp to reset last time (clamp dt max 100ms) so rAF continues, no acc issue. Good.
M → toggle mute.
Direction keys → if playing, enqueue.
});
enqueue([x,y]): const nd={x,y}; const lastDir = queue.length? queue[queue.length-1] : dir; if(nd.x===-lastDir.x&&nd.y===-lastDir.y) return; if(nd.x===lastDir.x&&nd.y===lastDir.y) return; if(queue.length<3) queue.push(nd);
```
This honors consecutive presses, prevents reversal against last queued.
Click/tap: board or overlay click: demo→start; over→start; paused→resume? Playing click does nothing. Touch swipe on board: on touchstart record, on touchmove compute delta, if >24px enqueue direction and reset origin (chain swipes allowed). preventDefault on touchmove on canvas to avoid scrolling (with {passive:false}).
Update loop:
```
function frame(now){
const dt=Math.min(100, now-last); last=now;
const running = state==='demo'||state==='playing';
if(running){
acc+=dt;
while(acc>=tickMs){ acc-=tickMs; step(); if(state!=='demo'&&state!=='playing') {acc=0;break;} }
}
updateFX(dt);
draw(now);
requestAnimationFrame(frame);
}
```
step():
```
if(state==='demo') dir = aiChoose(); else if(queue.length) dir=queue.shift();
const head=snake[0]; const nx=head.x+dir.x, ny=head.y+dir.y;
if(nx<0||ny<0||nx>=GRID||ny>=GRID) return die();
const tailWillMove = grow===0;
for(let i=0;i<snake.length;i++){
if(tailWillMove && i===snake.length-1) continue; // tail cell frees
if(snake[i].x===nx&&snake[i].y===ny) return die();
}
prevSnake = snake.map(p=>({x:p.x,y:p.y}));
snake.unshift({x:nx,y:ny});
if(grow>0) grow--; else snake.pop();
if(nx===food.x&&ny===food.y){ eat(); }
```
Note: prevSnake captured after collision check, before unshift. eat(): score+=10; grow+=1; tickMs=Math.max(MIN, tickMs-2.2); particles burst at food; floater '+10'; spawnFood(); update score chip; sound eat. If free cells empty → spawnFood sets food=null → win(): state 'won' with overlay. Handle: if(!food){ win(); }.
die(): dead=true; state = (state==='demo')? keep 'demo' but schedule reset: demoRestartTimer=900ms → reset to demo. Visual: red particles at head, shake=1, sound die (only if playing... in demo sound muted anyway since no gesture — but AudioContext may exist after user started game then went back? No, demo only before first start. Actually game-over only happens in playing; demo death auto-restarts). For playing: state='over'; update best (score>best → best=score, newBest=true), update chip, showOverlay('over'). Also freeze interpolation: on death, set prevSnake = snake.map(copy) so t is effectively 1 (prev==cur). Good.
aiChoose() as designed.
spawnFood(): build free list excluding snake; pick random; food={x,y}. Store foodSpawnTime for pop-in animation (scale from 0).
updateFX(dt): particles physics (vy += g*dt), life-=dt; floaters rise; shake decay; tongueTimer += dt, cycle; fireflies update (separate canvas).
draw(now):
```
ctx.clearRect... fill board bg.
// compute t
const t = (state==='demo'||state==='playing') ? Math.min(1, acc/tickMs) : 1;
// apply shake translate
ctx.save(); if(shake>0){ const m=shake*6; ctx.translate((Math.random()-.5)*m,(Math.random()-.5)*m); }
drawCells();
drawFood(now);
drawSnake(t, now);
drawParticles(); drawFloaters();
ctx.restore();
drawBezelInnerBorder();
```
drawCells: two passes: fill all with base, then fill cells where (x+y)%2 with subtle brighter. Cheap: 576 rects per frame OK? Only draw alternating: 288 fills. Fine. Plus a subtle radial glow at center? Use a radial gradient overlay once. ok.
drawSnake(t):
```
const pts = snake.map((s,i)=>{ const p=prevSnake[i]||s; return { x:(lerp(p.x,s.x,t)+0.5)*cell, y:(lerp(p.y,s.y,t)+0.5)*cell };});
```
Body path: use quadratic smoothing? For dense points, straight segments with round joins look great (segments are adjacent so it's grid-aligned path with right-angle corners; round joins make nice corners). Draw:
1. Dark outline: stroke width cell*0.78, color #0a3d23 alpha, shadowBlur for glow? Shadow is expensive but ok with 1 pass. Use shadowColor lime with blur 12 on outline pass → glow. Then inner gradient stroke width cell*0.62, linear gradient head→tail from lime→green→teal.
2. Belly stripe: stroke width cell*0.26, color rgba(255,255,210,.14), same path but offset? Offset is hard; instead skip or draw a darker line down center: width .2, rgba(6,40,20,.25) gives cylindrical shading. Do this: after main gradient, thin dark centerline width .18*cell rgba(0,0,0,.18) → depth.
Head: circle at pts[0], radius cell*0.46, filled with radial gradient brighter lime; eyes; tongue; if dead → X eyes.
Segment count small, fine.
drawFood: pulse = 1+0.08*sin(now/180); pop scale on spawn (min(1,(now-spawnAt)/250) eased). Radius base cell*0.34*pulse*pop. Draw shadow ellipse below; body radial gradient (#ff8a7a → #e5484d → #b91c1c); highlight small white ellipse alpha .7; leaf: rotated green ellipse; stem line. Also glow shadowBlur red.
Particles: additive: ctx.globalCompositeOperation='lighter' for particles → glowy feel. Reset afterward.
Floaters: monospace bold text '+10', rises 20px over 800ms, fade; draw with amber color, slight scale.
drawBezelInner: stroke rect inside edge color rgba lime .18 + corner ticks? Add tiny corner brackets for arcade flavor: four short L strokes at corners. Nice detail, cheap.
Fireflies canvas: init 26 particles {x,y,r,spd,phase,hue mix}; each frame update slowly upward + sine wobble; wrap. Draw with radial gradient or just fillCircle with alpha. Use 'lighter'. If reduced motion → skip animation (draw nothing, or static faint dots).
Overlay HTML templates:
demo:
```
<div class="ov-title">SNAKE</div>? Actually the title is already in header; overlay demo shows:
<div class="ov-badge">DEMO</div>
<h2 class="ov-title">READY?</h2>
<p class="ov-prompt blink">CLICK OR PRESS ANY KEY TO START</p>
<p class="ov-sub">ARROWS / WASD to move · P pause</p>
```
over:
```
<h2 class="ov-title over">GAME OVER</h2>
<div class="ov-score">120</div>
<div class="ov-best">BEST 150</div> (+ NEW BEST! badge)
<p class="ov-prompt blink">SPACE / ENTER — PLAY AGAIN</p>
```
paused:
```
<h2>PAUSED</h2><p class="ov-prompt">PRESS P TO RESUME</p>
```
Overlay style: background rgba(5,12,9,.62) + backdrop-filter blur(3px) — ok small. Panel? Full-cover simple, centered content. Titles use display font large, letter-spaced, glow. Prompt pulsates opacity.
Header markup:
```
<header>
<div class="brand">
<svg pixel snake 8x8 …>
<div><h1>SNAKE</h1><p class="tag">GARDEN ARCADE · 24×24</p></div>
</div>
<div class="scores">
<div class="chip"><span class="lbl">SCORE</span><span class="val" id="score">0</span></div>
<div class="chip best"><span class="lbl">BEST</span><span class="val" id="best">0</span></div>
</div>
</header>
```
Pixel snake SVG: draw path squares forming a small S-shape snake in lime with red apple. E.g., a 7x7 grid path. Let me build: cells (using 10px squares): make snake curve: (0,0),(1,0),(2,0),(2,1),(2,2),(1,2),(0,2),(0,3),(0,4),(1,4),(2,4)... Let's do simpler: horizontal body with head and apple: cells: head(5,1), body (4,1),(3,1),(3,2),(3,3),(4,3). apple at (6,0)? viewBox 0 0 80 40? Let me design 8x4 grid, 10px cells (80x40):
Snake cells: (0,2),(1,2),(2,2),(2,1),(3,1)? Hmm. Simpler approach: two rows:
Row y=1: x=1..4 lime (body), x=5 head darker? Actually head should be distinct: brighter.
Let's define: body: (1,1),(2,1),(3,1); turn down (3,2); (2,2)? That's reverse... whatever since it's a logo.
Simplest and readable: zigzag: (1,0),(2,0),(3,0),(3,1),(2,1)? no reverse... aesthetically fine since it's a logo.
Let me draw: cells: [1,1],[2,1],[3,1],[3,2],[2,2]?? That'd backtrack below. Visually like a hook — fine as logo. Head at [1,1]? Put head at end of line: [4,1] bigger brighter + eye pixel + apple at [6,1] red + leaf. Final: body cells x=0..3, y=1; head (4,1) 1.2x; tail taper at (0,1) smaller; apple (6,1) red with leaf. Also dot eye. Good enough; draw with rects in SVG with rounded 1px.
Footer hints: `<kbd>↑↓←→</kbd>/<kbd>WASD</kbd> move · <kbd>P</kbd> pause · <kbd>SPACE</kbd> restart · <kbd>M</kbd> sound` and a note about swipe on touch? Show always but muted; hide keyboard hints on small screens? Keep, wraps fine.
Also small status line: state indicator? Demo overlay has DEMO badge.
Sound implementation:
```
let actx=null, muted=false;
function ensureAudio(){ if(!actx){ try{ actx=new (window.AudioContext||window.webkitAudioContext)(); }catch(e){} } if(actx&&actx.state==='suspended') actx.resume(); }
function tone(f,d,type='square',g=0.04,delay=0,slide=0){ if(!actx||muted) return; const t0=actx.currentTime+delay; const o=actx.createOscillator(), ga=actx.createGain(); o.type=type; o.frequency.setValueAtTime(f,t0); if(slide) o.frequency.exponentialRampToValueAtTime(Math.max(40,f+slide), t0+d); ga.gain.setValueAtTime(0.0001,t0); ga.gain.exponentialRampToValueAtTime(g,t0+0.01); ga.gain.exponentialRampToValueAtTime(0.0001,t0+d); o.connect(ga).connect(actx.destination); o.start(t0); o.stop(t0+d+0.02); }
sEat=()=>{tone(620,.08,'square',.05); tone(930,.1,'square',.05,.07);}
sDie=()=>{tone(300,.25,'sawtooth',.06,0,-220); tone(150,.3,'triangle',.05,.12,-90);}
sStart=()=>{tone(440,.07,'triangle',.05); tone(660,.09,'triangle',.05,.08);}
sTurn? maybe small click on turn — might be noisy; skip.
```
Best score updated only from playing runs (demo score doesn't count — correct: session best is for player runs; demo shouldn't pollute. In demo, score accumulates but on demo death we just don't touch best, and on real start reset score. Chips show score 0 during demo? Demo overlay covers; chips show SCORE 0 and BEST n. On demo reset, scoreEl=0. Fine.)
Now careful with timing on acc reset on start/pause-resume. On startGame: acc=0; last handled via dt clamp.
Potential bug: during pause, rAF continues drawing (t=1 because state not running → t clamps to acc/tickMs which might be <1 → snake frozen mid-cell: fine, we set t=1 when not running? That would jump snake forward. Better: freeze: if paused/over, use frozenT that was saved. Save renderT every frame while running; when not running, reuse last renderT. Implementation: let renderT=0; if running compute renderT = min(1, acc/tickMs); draw uses renderT. On death, snake should be fully within cell (step only completes when head lands in cell; interpolation continues to 1 after step... actually after step, acc < tickMs so renderT is small → snake already at new cell? Wait: step moves snake when acc exceeded tickMs, then acc-=tickMs → renderT ≈ 0 → prevSnake is old position, snake is new, t≈0 → draws old position. That's continuous motion — correct). At death moment, renderT≈0 → snake appears at pre-collision position; head hasn't visually entered wall. Death particles at head's current interpolated position — head cell center. Acceptable. To be dramatic, maybe particles at collision point: nx,ny cell center (inside wall/body). Let's spawn red burst at the collision point (the nx,ny attempted). Nice.
Also freeze renderT on 'over' → static. Good.
Restart from over: reset clears.
Also handle visibility change: when hidden, last=performance.now() on visible to avoid jump — dt clamp 100ms handles.
Grid coords → px helper: cx=(i)=>(i+0.5)*cellPx... Use directly.
Food never spawns on snake: guaranteed by free list.
Win condition (snake fills grid): show overlay "YOU WIN". Include for completeness.
Demo auto-restart: on die() when state==='demo': shake, particles, then setTimeout(900) → resetDemo(). Guard double timer. Also hide any? Demo overlay stays visible (start prompt persists during demo death) — good, prompt is persistent. Keep overlay pointer-events none during demo? Click anywhere starts — attach click handler to overlay and board. Overlay covers board; give overlay click handler: if demo→start; if over→start; if paused→resume.
During playing, overlay hidden (display none) so clicks reach board; board click during playing: nothing (maybe ignore). Swipes work.
Edge: user presses P during demo → ignore. Space during demo → start (handled by any key).
Now let me write CSS carefully.
Body: margin 0; min-height 100vh; display flex center; overflow-x hidden; background layers:
```
background:
radial-gradient(1100px 700px at 12% -10%, rgba(45,212,149,.10), transparent 60%),
radial-gradient(900px 600px at 108% 110%, rgba(251,191,36,.07), transparent 60%),
radial-gradient(700px 500px at 85% -20%, rgba(163,230,53,.06), transparent 60%),
#060d0a;
```
Plus faint grid texture via body::before fixed, background-image: linear-gradient of 1px lines every 34px, opacity .5, mask with radial fade? Keep it simple: opacity .35, color rgba(163,230,53,.04). Also a slow drift animation on ::before? Can animate background-position slowly (60s linear infinite). Cheap ambient. ok.
Fireflies canvas: fixed inset 0 z-index 0 pointer-events none.
.frame: position relative z-index 1; width min(96vw, 660px); display flex column; gap 14px; padding 20px 0 28px.
Header: flex space-between align-end; gap; flex-wrap.
Brand: flex gap 12 align-center. h1: font: display stack; size clamp(30px,4.5vw,42px); letter-spacing .12em; color var(--lime); text-shadow glow: 0 0 18px rgba(163,230,53,.45), 0 2px 0 rgba(0,0,0,.4). Tag: 11px letterspace .3em uppercase muted.
Chip: bg #0c1a12 border 1px #234531; radius 10px; padding 8px 14px; min-width 92px; label 10px letterspacing muted; value monospace bold 26px tabular-nums; score value lime, best amber; chip hover translateY(-2px) transition; box-shadow inset top highlight. Pop animation on .val.pop: keyframes scale 1→1.35→1 color flash.
Best chip gets a tiny star? Add "★" prefix? Skip.
Board bezel: .board-wrap{position:relative; width:100%; aspect-ratio:1; border-radius:14px; padding:10px; background:linear-gradient(180deg,#12241a,#0a150f); border:1px solid #274936; box-shadow: 0 20px 60px -20px rgba(0,0,0,.8), 0 0 40px -10px rgba(52,211,153,.25), inset 0 1px 0 rgba(255,255,255,.05);} canvas{width:100%;height:100%;display:block;border-radius:8px;background:#0b1610;} entrance animation: @keyframes rise {from{opacity:0;transform:translateY(14px) scale(.98)}} apply to .board-wrap and header with staggered animation-delay.
Scanline: .board-wrap::after{content:'';position:absolute;inset:10px;border-radius:8px;pointer-events:none;background:repeating-linear-gradient(0deg, rgba(255,255,255,.025) 0 1px, transparent 1px 3px); mix-blend? none. opacity .5;} Subtle.
Overlay: .overlay{position:absolute; inset:10px; border-radius:8px; display:flex; flex-direction:column; align-items:center; justify-content:center; gap:10px; text-align:center; background:rgba(4,10,7,.58); backdrop-filter:blur(3px); transition:opacity .25s; z-index:3;} .overlay.hidden{opacity:0;pointer-events:none;} Content styled: .ov-badge{font 10px letterspace .4em; color amber; border 1px solid amber-ish; padding 4px 10px; radius 999px; } .ov-title{font display; clamp(30px,6vw,54px); letterspacing .14em; color lime glow;} .ov-title.over{color:var(--red); shadow red} .ov-score-num big 46px amber monospace. .ov-prompt{font monospace 13px letterspacing .2em; color #d7f3d0; animation pulse 1.2s infinite;} .ov-sub muted 12px. .newbest{color:#0b1410;background:amber;padding 3px 10px;radius:4px;font-weight:800;letterspacing .15em; font-size 11px; animation pop}
Footer hints: monospace 12px muted; kbd{border:1px solid #2c523c; border-bottom-width:2px; padding:2px 7px; radius:6px; background:#0d1c13; color:#cfe9d2; font:inherit;} Hover kbd lift.
Small screens: header stacks column align-start? Keep row wrap; chips shrink (val 20px). Hide tag on <420px? Fine to keep with wrap.
Now writing AI's safe check:
```
function occupied(x,y,excludeTail){ for(let i=0;i<snake.length;i++){ if(excludeTail&&i===snake.length-1) continue; if(snake[i].x===x&&snake[i].y===y) return true;} return false;}
```
aiChoose: tailMoves = grow===0. Candidates straight/left/right. Evaluate safety of next cell: in-bounds && !occupied(nx,ny,tailMoves). Score: base -(Manhattan to food)*2; escapes: count of neighbors of (nx,ny) that are in-bounds && !occupied(…, tailMoves) && !(== head position current? head will move so head cell becomes free... after moving, the head vacates its cell; body shifts. Approx: treat current head cell as free.) Count escapes excluding current head cell as blocked. Weight escapes*1.4, dead-end (escapes===0) heavy penalty -50. Straight +0.6. Random 0.5. If no safe candidates → return dir (die → restart).
turnLeft(d) = {x:d.y, y:-d.x}; turnRight = {x:-d.y, y:d.x}.
Demo difficulty: should demo eventually die to showcase restart? AI with lookahead rarely dies on 24×24 until long snake; demo could run long — that's fine (it's meant to attract). But requirement is game-over overlay visible? "All interesting things should be visible in the first seconds" — smooth snake movement with eyes, food, particles occasionally (AI eats food — yes, it does). Game over itself may not show in first 3s — but overlay prompt is visible. Fine. Maybe make AI slightly imperfect (small random) to eventually die and show death burst. Jitter random*0.8 included. ok.
Floating text font: '700 14px ui-monospace...' scaled by cell? Use cellPx*0.42 px.
Now the draw() specifics:
drawCells:
```
ctx.fillStyle='#0b1610'; ctx.fillRect(0,0,b,b);
ctx.fillStyle='rgba(163,230,53,0.035)';
for(x...)if((x+y)&1) fillRect(x*c,y*c,c,c);
// center ambient glow
let g=ctx.createRadialGradient(b/2,b/2,b*0.1,b/2,b/2,b*0.75); g.addColorStop(0,'rgba(52,211,153,.06)');g.addColorStop(1,'rgba(0,0,0,0)'); fill;
```
Border after restore: stroke rect 1.5 inset rgba lime .12.
Corner brackets: at 4 corners draw an L 14px long lime .35 alpha 2px.
Food glow: ctx.shadowColor='rgba(255,80,70,.55)', blur 16 on apple body.
Snake glow: shadowBlur 14 color rgba(130,230,120,.5) on outline pass.
Particle draw: circles radius r*(life) with lighter composite.
Floater draw: alpha=life; y-offset.
Shake decay: shake=Math.max(0,shake-dt/300).
Tongue: cycle = (now%2200); if cycle<260 flicker; draw when alive and not dead.
Also draw eyes with slight squash on turn? Skip.
Head radius: cell*0.5? Body width .62cell → head circle diameter ~.9cell slightly bigger. Draw head after body: radial gradient fill (#d9f99d center → #65d95c → #2fae5f). Eyes on top.
Dead X eyes: two lines crossing each eye position, stroke #08221a? On death, head turns duller. Also draw red burst.
Draw order for head: head circle, then eyes; tongue under head? Tongue emerges from mouth → draw tongue first (below head circle) extending forward: draw tongue before head circle.
Interpolation helper lerp.
PrevSnake init on reset: same as snake.
Now resize(): wrap is CSS-driven; canvas CSS width 100% height 100%; on resize read wrap.clientWidth-20? Actually the padding is on the wrapper; canvas fills inner content box. canvas.clientWidth returns layout width. Use canvas.clientWidth/clientHeight. Set width=clientWidth*dpr etc. Store b=clientWidth, cell=b/GRID. Called on load + resize event + ResizeObserver on canvas for safety. Use ResizeObserver (widely supported).
Draw uses ctx.setTransform(dpr,0,0,dpr,0,0) at each resize.
Overlay covers board but inside bezel padding inset 10px — matches canvas radius. Good.
Now let's assemble final HTML. Include <meta viewport> too. Title "SNAKE — Garden Arcade".
Recheck reversal logic with queue: initial dir right; press left → lastDir=dir right → opposite → ignored. Press up then left quickly: enqueue up (vs right ok), then left vs last queued up → ok; tick1 turn up, tick2 turn left. Honored.
Pause: while paused, running=false so acc frozen; on resume dt clamp fine.
Space during playing: Space isn't direction; ignore during playing (or maybe pause? Requirement: space to restart — on game over. During playing, nothing.)
Enter during playing: nothing.
Keydown repeat: e.repeat — for direction keys, holding causes repeat enqueue; tolerable (classic). But Space repeat on over → restart repeatedly? Guard: if(e.repeat && (code==='Space'||'Enter'||'KeyP')) return.
Demo overlay shown initially: on init call resetDemo + showOverlay('demo'). Also score chip 0.
Audio gesture: keydown/click call ensureAudio. Sound effects only invoked during playing path: startGame plays sStart; eat/die only when playing? die can happen in demo too — guard sound: if(state==='playing') sDie... in die(), state is playing before we set over; demo: state demo → skip sound. eat: demo eats too → skip sound unless playing. Score chip update: demo eating shouldn't bump SCORE chip? Demo score doesn't count; but chip shows 0 while demo snake eats... a bit odd but overlay mostly covers center; chips visible on top. Let's have chips show demo score as "00"? Simpler: still increment score variable in demo (used for speed-up), and update chip — shows life. Actually that's misleading as "current score" in a session. Hmm. I think it's cleaner to keep chip at 0 during demo: only update chip when state==='playing'. Demo resets score internally anyway. Do this: in eat(), if(state==='playing') update chip.
Best chip flashes when new best: add class 'flash' animation on chip.
Let me write everything out, minding syntax errors.
Also: prevent arrow-key scrolling: preventDefault on arrow/space handled.
Touch: on touchmove passive false on the board area to stop scroll; but page fits anyway; only preventDefault on canvas when swiping (delta>10). Simple: on canvas.addEventListener('touchmove', e=>{ if(!touchStart) return; e.preventDefault(); ...}, {passive:false}).
Swipe direction: dominant axis delta.
Also 'pointerdown' maybe simpler; use touch events separately. Click for start works with mouse; on mobile tap triggers click → starts. Then swipe to steer.
One more requirement check: "Grid of at least 20×20 cells, centered on page" — 24×24, canvas centered via frame centering. ✔️
"Eating food gives +10 points, grows snake by one cell" ✔️ grow+=1.
"New food never spawns on snake" ✔️ free list.
"Collision with wall or self causes game over, with overlay showing score" ✔️. "Space or Enter to restart" ✔️. "P to pause" ✔️ pause overlay.
"Smooth motion" ✔️ interpolation. "Distinct head and eyes oriented in direction" ✔️. "Particle burst" ✔️.
"Display current score and session best" ✔️ chips.
Now let me write out the final code, minding syntax errors.
Structure of script:
```
(() => {
'use strict';
// constants & elements
const GRID=24, BASE_TICK=132, MIN_TICK=72;
const cvs=document.getElementById('game'), ctx=cvs.getContext('2d');
const bgCvs=..., bctx=...
const overlay=..., scoreEl=..., bestEl=..., scoreChip, bestChip
const wrap=document.getElementById('bezel');
let dpr=1, b=0, cell=0;
function fit(){ dpr=Math.min(2.5,window.devicePixelRatio||1); b=cvs.clientWidth; cvs.width=Math.round(b*dpr); cvs.height=Math.round(b*dpr); ctx.setTransform(dpr,0,0,dpr,0,0); cell=b/GRID; fitBg(); }
function fitBg(){ bgCvs.width=innerWidth*dpr... set transform }
window.addEventListener('resize', ()=>{fit();});
```
Use requestAnimationFrame for fit? Fine.
Reduced motion: const RM = matchMedia('(prefers-reduced-motion: reduce)').matches;
Game state vars. Functions: reset(demo), spawnFood, enqueue, step, eat, die, aiChoose, startGame, showOverlay(kind), hideOverlay, updateChips.
Overlay builder:
```
function showOverlay(kind){
overlay.classList.remove('hidden');
if(kind==='demo') overlay.innerHTML = `...`;
...
}
```
Include dynamic numbers for over.
Overlay click:
```
overlay.addEventListener('click', ()=>{ if(state==='demo'||state==='over'||state==='won') startGame(); else if(state==='paused'){state='playing';hideOverlay();} });
```
Particles:
```
function burst(cx,cy,colors,n=20,spd=0.32){ for... particles.push({x,y,vx,vy,life:1,decay: 0.9+..., r, col}) }
```
Units: px per ms. Gravity 0.0009 px/ms².
updateFX:
```
for(p of particles){p.x+=p.vx*dt; p.y+=p.vy*dt; p.vy+=0.0011*dt; p.life-=dt*p.decay/600? }
```
Let's define life ms: p.t initial 500–800ms; p.age+=dt; alpha=1-age/t.
Simplify: p={x,y,vx,vy,r,t:0,dur,col}; alpha=1-p.t/p.dur; remove when t>dur.
Floaters: {x,y,t,dur:900,text}; y rises: draw at y - 26*(t/dur) ease-out; alpha fade after half.
Fireflies:
```
const flies=[]; for 26: {x:Math.random(),y:Math.random(),r:1+Math.random()*2.2,s:.004+..., ph:rand*6.28, hue: pick green/amber}
draw on bg each frame: clear, if(!RM || first) update y -= s*dt... normalized coords; wrap; alpha = .18+.14*sin(now/900+ph).
```
Draw on bg canvas with 'lighter'. If RM → draw static faint dots once? Just skip animation, update positions but that's motion... skip entirely: don't render flies if RM.
draw(now) main as planned.
Compute interpolated points once per draw.
drawEyes(hp, dvec): hp=head point px, d=direction {x,y}, p=perp.
Eye offset forward 0.14cell, side 0.19cell, eyeR .155cell, pupilR .075 offset forward .05cell.
If dead: draw X: two lines per eye crossing, size eyeR*1.1, stroke '#0d271d' width 2.
Tongue: from hp + d*(0.38cell) to hp + d*(0.72cell) plus fork: two short lines from tip at ±(d rotated)*0.1cell. Only when flicker window and alive and state not over... during demo flicker too — fun.
Flicker window: const cyc=now%2400; flick = cyc<220 || (cyc>300&&cyc<380); something double-flick.
Body gradient: createLinearGradient(head.x,head.y,tail.x,tail.y) stops: 0 '#c8f26a'? Actually head is drawn separately brighter; body: 0 '#8fe14f', .5 '#3ecf74', 1 '#17a06b'. Outline: '#0c3b24' w= cell*0.8, with lime shadow for glow.
Also small belly: stroke rgba(10,40,25,.28) w cell*0.2 along path.
Dark dot pattern on body? Skip.
Also draw a soft shadow under snake? Skip, glow suffices.
Board inner border + corner brackets drawn last.
Vignette on board: radial gradient rgba(0,0,0,.28) at edges → transparent center, drawn after everything (before bezel border) for depth. Subtle.
OK — initial food pop time: foodBorn=performance.now().
Also on eat, re-set foodBorn.
Speed display? Show length in game over overlay: "LENGTH 12". Nice.
Also show session BEST in demo overlay.
Let me write the final HTML now. Use lang="en" (request is English).
Font stacks:
--display: ui-monospace, "Cascadia Code", "SF Mono", Menlo, Consolas, "Liberation Mono", monospace — hmm for big title maybe want extra bold condensed feel: use font-weight 800 letterspacing. Mono title reads techy/snake-terminal. Body: system-ui, -apple-system, "Segoe UI", Roboto... That pairing works (distinctive display + legible body).
Actually to make display more distinctive, could also use "Impact" — but mono aligns with Snake's heritage. Go mono, weight 800, uppercase, spaced.
Value is tabular: font-variant-numeric: tabular-nums.
Edge case: overlay inside bezel padding inset:10px matches bezel padding 10.
Now generate code.
```html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>SNAKE · Garden Arcade</title>
<style>
:root{
--bg:#060d0a; --panel:#0c1a12; --line:#24462f;
--lime:#a3e635; --mint:#5eead4; --green:#34d399; --amber:#fbbf24; --red:#ff6b5e;
--ink:#e9f6ea; --muted:#84a98f;
--mono: ui-monospace,"Cascadia Code","SF Mono",Menlo,Consolas,"Liberation Mono",monospace;
--sans: system-ui,-apple-system,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;
}
*{box-sizing:border-box}
html,body{margin:0}
body{
min-height:100vh; display:flex; align-items:center; justify-content:center;
font-family:var(--sans); color:var(--ink); overflow-x:hidden;
background:
radial-gradient(1100px 720px at 10% -12%, rgba(52,211,153,.11), transparent 62%),
radial-gradient(900px 640px at 106% 112%, rgba(251,191,36,.08), transparent 60%),
radial-gradient(760px 520px at 88% -18%, rgba(163,230,53,.07), transparent 60%),
var(--bg);
}
body::before{ content:""; position:fixed; inset:-40px; pointer-events:none; z-index:0;
background-image:
linear-gradient(rgba(163,230,53,.05) 1px, transparent 1px),
linear-gradient(90deg, rgba(163,230,53,.05) 1px, transparent 1px);
background-size:36px 36px;
mask-image: radial-gradient(75% 75% at 50% 45%, #000 30%, transparent 100%);
-webkit-mask-image: radial-gradient(75% 75% at 50% 45%, #000 30%, transparent 100%);
animation: gridDrift 70s linear infinite;
}
@keyframes gridDrift{ to{ background-position: 36px 72px, 72px 36px; } }
#bg{ position:fixed; inset:0; width:100%; height:100%; z-index:0; pointer-events:none; }
.frame{ position:relative; z-index:1; width:min(96vw,640px); display:flex; flex-direction:column; gap:14px; padding:22px 0 26px; }
...
```
Header/brand/svg etc. Let me write SVG pixel snake:
```
<svg class="mark" viewBox="0 0 96 40" width="52" height="22" aria-hidden="true">
<g>
<!-- tail -->
<rect x="2" y="20" width="8" height="8" rx="2" fill="#3ea860"/>
<rect x="10" y="18" width="10" height="10" rx="2" fill="#4ecf6e"/>
<rect x="20" y="16" width="12" height="12" rx="3" fill="#63dd6f"/>
<rect x="32" y="14" width="14" height="14" rx="3" fill="#7fe96a"/>
<!-- head -->
<rect x="46" y="11" width="18" height="18" rx="5" fill="#a3e635"/>
<circle cx="58" cy="17" r="2.4" fill="#0b2417"/>
<!-- tongue -->
<path d="M64 20h6m0 0l3 -2m-3 2l3 2" stroke="#ff6b5e" stroke-width="2" stroke-linecap="round" fill="none"/>
<!-- apple -->
<circle cx="82" cy="22" r="8" fill="#ff5148"/>
<circle cx="79.5" cy="19.5" r="2.4" fill="#ffd2c4" opacity=".8"/>
<path d="M82 14c1-3 3-4 5-4" stroke="#7ee06a" stroke-width="2.4" stroke-linecap="round" fill="none"/>
</g>
</svg>
```
Tongue path start is at head right edge x=64 (46+18). Ok. Animate the mark with slight bob? .mark{animation:bob 4s ease-in-out infinite}. Subtle.
Scores, bezel, overlay CSS, footer.
Overlay pulse:
@keyframes blinkPrompt{0%,100%{opacity:1}50%{opacity:.35}}
Chip pop keyframes.
Now JS. Let me write full carefully.
```
(()=>{
'use strict';
const GRID=24, BASE_TICK=132, MIN_TICK=70, WIN_SCORE=GRID*GRID*10;
const cvs=document.getElementById('game');
const ctx=cvs.getContext('2d');
const bgCvs=document.getElementById('bg');
const bctx=bgCvs.getContext('2d');
const overlay=document.getElementById('overlay');
const scoreEl=document.getElementById('score');
const bestEl=document.getElementById('best');
const bestChip=document.getElementById('chipBest');
const RM=matchMedia('(prefers-reduced-motion: reduce)').matches;
let dpr=1,b=0,cell=0,bw=0,bh=0;
function fit(){
dpr=Math.min(2.5,window.devicePixelRatio||1);
b=cvs.clientWidth||300;
cvs.width=Math.max(1,Math.round(b*dpr));
cvs.height=Math.max(1,Math.round(b*dpr));
ctx.setTransform(dpr,0,0,dpr,0,0);
cell=b/GRID;
bw=innerWidth; bh=innerHeight;
bgCvs.width=Math.round(bw*dpr); bgCvs.height=Math.round(bh*dpr);
bctx.setTransform(dpr,0,0,dpr,0,0);
}
new ResizeObserver(fit).observe(cvs);
addEventListener('resize',fit);
```
ResizeObserver on cvs fires on size change; initial call needed too: RO fires initially upon observe. Good. But note: on first RO callback, font/layout settled — fine.
Fireflies:
```
const flies=[];
for(let i=0;i<26;i++)flies.push({x:Math.random(),y:Math.random(),r:.8+Math.random()*2.4,s:.006+Math.random()*.014,ph:Math.random()*6.283,warm:Math.random()<.3});
```
Update: y -= s*dt/1000; x += sin(now/1400+ph)*0.00012*dt; wrap y< -0.05 → 1.05.
Game state:
```
let state='demo', snake=[], prevSnake=[], dir={x:1,y:0}, queue=[], grow=0, food={x:0,y:0}, foodBorn=0;
let score=0, best=0, tickMs=BASE_TICK, acc=0, renderT=0, last=performance.now();
let particles=[], floaters=[], shake=0, deadTimer=0, demoTimer=null;
```
reset():
```
function reset(){
const cy=Math.floor(GRID/2), sx=Math.floor(GRID*0.30);
snake=[]; for(let i=0;i<4;i++)snake.push({x:sx-i,y:cy});
prevSnake=snake.map(p=>({...p}));
dir={x:1,y:0}; queue=[]; grow=0; score=0; tickMs=BASE_TICK; acc=0; renderT=0;
particles=[]; floaters=[]; shake=0;
spawnFood();
if(state!=='demo') setScoreChip();
}
```
Hmm, call reset from startGame (state already 'playing') and from demo start (state='demo'). Chip: update always to 0 anyway (demo chip shows 0) — just set chip always. ok: setScoreChip().
spawnFood:
```
function spawnFood(){
const occ=new Set(snake.map(s=>s.x+','+s.y));
const free=[];
for(let y=0;y<GRID;y++)for(let x=0;x<GRID;x++){const k=x+','+y; if(!occ.has(k))free.push({x,y});}
if(!free.length){food=null; return;}
food=free[(Math.random()*free.length)|0];
foodBorn=performance.now();
}
```
eat():
```
function eat(){
score+=10; grow+=1;
tickMs=Math.max(MIN_TICK, tickMs-2.4);
const px=(food.x+.5)*cell, py=(food.y+.5)*cell;
burst(px,py,['#ffd76a','#a3e635','#fff7d6','#5eead4'],22,.28);
floaters.push({x:px,y:py,t:0,dur:900,text:'+10'});
if(state==='playing'){ setScoreChip(); sEat(); }
spawnFood();
if(!food){ finish(true); }
}
```
Wait, win only possible in playing; demo reaching full is unrealistic. finish(true) → state 'won', overlay won, best update. Let me unify with die → finish(false).
Actually simpler: separate function gameOver(won).
die(nx,ny):
```
function die(nx,ny){
shake=RM?0:1;
const px=(nx!=null?(nx+.5):(snake[0].x+.5))*cell, py=(ny!=null?(ny+.5):(snake[0].y+.5))*cell;
burst(px,py,['#ff6b5e','#ffb454','#ff9d8a'],26,.34);
if(state==='demo'){
clearTimeout(demoTimer);
demoTimer=setTimeout(()=>{ if(state==='demo'){ reset(); showOverlay('demo'); } },1000);
// Stop snake motion: set state to 'demoDead'?
}
}
```
Need to stop stepping after death. Introduce state 'demoDead' and let demo continue to draw static snake behind overlay until timer resets. In frame loop, running only when state==='demo'||'playing'. 'demoDead' → no step, draw static (renderT frozen). Good.
For playing:
```
state='over';
if(score>best){best=score; newBest=true; bestEl.textContent=best; bestChip.classList.add('flash');}
showOverlay('over');
sDie();
```
Also record length for overlay: snake.length.
Overlay templates:
```
function showOverlay(kind){
overlay.classList.remove('hidden');
if(kind==='demo'){
overlay.innerHTML=`
<div class="ov-badge">◈ ATTRACT MODE ◈</div>
<h2 class="ov-title">SNAKE</h2>
<p class="ov-prompt">CLICK OR PRESS ANY KEY TO START</p>
<p class="ov-sub">ARROW KEYS / WASD TO STEER · P PAUSE</p>
${best>0?`<p class="ov-best-line">SESSION BEST — ${best}</p>`:''}`;
} else if(kind==='over'){
overlay.innerHTML=`
<h2 class="ov-title over">GAME OVER</h2>
<div class="ov-score">${score}</div>
<div class="ov-meta">LENGTH ${snake.length} · BEST ${best}${newBestFlag?' <span class="newbest">NEW BEST!</span>':''}</div>
<p class="ov-prompt">SPACE / ENTER — PLAY AGAIN</p>`;
} else if(kind==='won'){...similar 'PERFECT GAME'}
else paused:
overlay.innerHTML=`<h2 class="ov-title">PAUSED</h2><p class="ov-prompt">PRESS P TO RESUME</p>`;
}
```
Track newBestFlag per run.
hideOverlay: add hidden class.
startGame():
```
function startGame(){
clearTimeout(demoTimer);
reset();
state='playing'; newBestFlag=false;
hideOverlay();
sStart();
last=performance.now();
}
```
step():
```
function step(){
if(state==='demo'){ dir=aiChoose(); }
else if(queue.length){ dir=queue.shift(); }
const h=snake[0], nx=h.x+dir.x, ny=h.y+dir.y;
if(nx<0||ny<0||nx>=GRID||ny>=GRID){ die(nx,ny); return; }
const tailMoves=grow===0;
for(let i=0;i<snake.length;i++){
if(tailMoves&&i===snake.length-1)continue;
if(snake[i].x===nx&&snake[i].y===ny){ die(nx,ny); return; }
}
prevSnake=snake.map(p=>({x:p.x,y:p.y}));
snake.unshift({x:nx,y:ny});
if(grow>0)grow--; else snake.pop();
if(food&&nx===food.x&&ny===food.y) eat();
}
```
aiChoose:
```
function aiChoose(){
const h=snake[0];
const cands=[dir,{x:dir.y,y:-dir.x},{x:-dir.y,y:dir.x}];
const tailMoves=grow===0;
let bestD=dir, bestS=-1e9;
for(const d of cands){
const nx=h.x+d.x, ny=h.y+d.y;
if(nx<0||ny<0||nx>=GRID||ny>=GRID)continue;
if(hitBody(nx,ny,tailMoves))continue;
let s=-(Math.abs(nx-food.x)+Math.abs(ny-food.y))*2;
let esc=0;
for(const e of DIRS4){
const ex=nx+e.x, ey=ny+e.y;
if(ex<0||ey<0||ex>=GRID||ey>=GRID)continue;
if(ex===h.x&&ey===h.y){esc++;continue;} // head vacates
if(!hitBody(ex,ey,tailMoves))esc++;
}
s+=esc*1.6;
if(esc<=1)s-=12;
if(d===dir)s+=.7;
s+=Math.random()*.9;
if(s>bestS){bestS=s;bestD=d;}
}
return bestD;
}
function hitBody(x,y,tailMoves){
for(let i=0;i<snake.length;i++){
if(tailMoves&&i===snake.length-1)continue;
if(snake[i].x===x&&snake[i].y===y)return true;
}
return false;
}
const DIRS4=[{x:1,y:0},{x:-1,y:0},{x:0,y:1},{x:0,y:-1}];
```
food may be null (win) — if food null then no step anyway (finish called). Guard in ai: if(!food) return dir.
Input:
```
const KEYDIR={ArrowUp:[0,-1],KeyW:[0,-1],ArrowDown:[0,1],KeyS:[0,1],ArrowLeft:[-1,0],KeyA:[-1,0],ArrowRight:[1,0],KeyD:[1,0]};
addEventListener('keydown',e=>{
const c=e.code;
if(c==='Space'||c==='Enter'||c.startsWith('Arrow'))e.preventDefault();
ensureAudio();
if(state==='demo'){ startGame(); const kd=KEYDIR[c]; if(kd)enqueue(kd[0],kd[1]); return; }
if(state==='demoDead')return;
if(state==='over'||state==='won'){ if((c==='Space'||c==='Enter')&&!e.repeat)startGame(); return; }
if(c==='KeyP'&&!e.repeat){
if(state==='playing'){state='paused';showOverlay('paused');}
else if(state==='paused'){state='playing';hideOverlay();}
return;
}
if(c==='KeyM'&&!e.repeat){muted=!muted; muteEl... maybe update footer indicator; return;}
const kd=KEYDIR[c];
if(kd&&state==='playing')enqueue(kd[0],kd[1]);
});
function enqueue(x,y){
const lastD=queue.length?queue[queue.length-1]:dir;
if(x===lastD.x&&y===lastD.y)return;
if(x===-lastD.x&&y===-lastD.y)return;
if(queue.length>=3)return;
queue.push({x,y});
}
```
M indicator: small span in footer id="snd" text "SOUND ON/OFF"? Let's do that, update.
Click:
```
overlay.addEventListener('click',()=>{ ensureAudio();
if(state==='demo'||state==='over'||state==='won')startGame();
else if(state==='paused'){state='playing';hideOverlay();}
});
```
Also board click during playing does nothing.
Touch swipe:
```
let tS=null;
cvs.addEventListener('touchstart',e=>{ensureAudio(); if(state==='demo'||state==='over'||state==='won'){startGame(); e.preventDefault(); return;} tS={x:e.touches[0].clientX,y:e.touches[0].clientY};},{passive:false});
cvs.addEventListener('touchmove',e=>{
if(!tS||state!=='playing')return;
e.preventDefault();
const t=e.touches[0], dx=t.clientX-tS.x, dy=t.clientY-tS.y;
if(Math.abs(dx)<22&&Math.abs(dy)<22)return;
if(Math.abs(dx)>Math.abs(dy))enqueue(Math.sign(dx),0); else enqueue(0,Math.sign(dy));
tS={x:t.clientX,y:t.clientY};
},{passive:false});
cvs.addEventListener('touchend',()=>tS=null);
```
Note: overlay covers canvas during demo; overlay click handles start; the touchstart on canvas under overlay won't fire because overlay intercepts — that's fine, overlay has click (touch also triggers click). ok.
Chip update with pop:
```
function setScoreChip(){scoreEl.textContent=score; pop(scoreEl);}
function pop(el){el.classList.remove('pop');void el.offsetWidth;el.classList.add('pop');}
```
Audio as planned, sEat/sDie/sStart guarded on actx.
Frame loop:
```
function frame(now){
const dt=Math.min(100,now-last); last=now;
const running=state==='demo'||state==='playing';
if(running){
acc+=dt;
while(acc>=tickMs){ acc-=tickMs; step(); if(!running&&state!=='demo'&&state!=='playing')break; if(state==='demoDead'||state==='over'||state==='won')break; }
renderT=Math.min(1,acc/tickMs);
}
updateFX(dt);
drawBoard(now);
drawFlies(now,dt);
requestAnimationFrame(frame);
}
```
Simplify the while condition: while(acc>=tickMs && (state==='demo'||state==='playing')){...}.
updateFX: iterate particles/floaters, remove expired; shake decay: shake=Math.max(0,shake-dt/260).
drawBoard(now):
```
ctx.clearRect(0,0,b,b);
// bg
ctx.fillStyle='#0a1710';ctx.fillRect(0,0,b,b);
ctx.fillStyle='rgba(163,230,53,.045)';
for(let y=0;y<GRID;y++)for(let x=0;x<GRID;x++)if((x+y)&1)ctx.fillRect(x*cell,y*cell,cell,cell);
// ambient center glow
let rg=ctx.createRadialGradient(b/2,b/2,b*.08,b/2,b/2,b*.7);
rg.addColorStop(0,'rgba(74,222,128,.07)');rg.addColorStop(1,'rgba(0,0,0,0)');
ctx.fillStyle=rg;ctx.fillRect(0,0,b,b);
ctx.save();
if(shake>0){const m=shake*7;ctx.translate((Math.random()-.5)*m,(Math.random()-.5)*m);}
if(food)drawFood(now);
drawSnake(now);
drawParticles();
drawFloaters();
ctx.restore();
// vignette
let vg=ctx.createRadialGradient(b/2,b/2,b*.35,b/2,b/2,b*.72);
vg.addColorStop(0,'rgba(0,0,0,0)');vg.addColorStop(1,'rgba(0,0,0,.32)');
ctx.fillStyle=vg;ctx.fillRect(0,0,b,b);
// border
ctx.strokeStyle='rgba(163,230,53,.16)';ctx.lineWidth=1.5;ctx.strokeRect(.75,.75,b-1.5,b-1.5);
// corner brackets
ctx.strokeStyle='rgba(163,230,53,.5)';ctx.lineWidth=2;
const L=Math.max(10,b*.03);
corner paths...
```
drawFood:
```
const pop=Math.min(1,(now-foodBorn)/240); const ease=1-Math.pow(1-pop,3);
const pulse=1+.07*Math.sin(now/170);
const cx=(food.x+.5)*cell, cy=(food.y+.5)*cell, r=cell*.34*ease*pulse;
// shadow
ctx.fillStyle='rgba(0,0,0,.35)';ctx.beginPath();ctx.ellipse(cx,cy+r*.8,r*.9,r*.32,0,0,7);ctx.fill();
ctx.save();ctx.shadowColor='rgba(255,80,64,.55)';ctx.shadowBlur=cell*.6;
const g=ctx.createRadialGradient(cx-r*.35,cy-r*.4,r*.15,cx,cy,r);
g.addColorStop(0,'#ff9d8a');g.addColorStop(.55,'#ff5148');g.addColorStop(1,'#c22626');
ctx.fillStyle=g;ctx.beginPath();ctx.arc(cx,cy,r,0,7);ctx.fill();ctx.restore();
// highlight
ctx.fillStyle='rgba(255,255,255,.75)';ctx.beginPath();ctx.ellipse(cx-r*.35,cy-r*.42,r*.22,r*.14,-0.6,0,7);ctx.fill();
// stem+leaf
ctx.strokeStyle='#8a5a33';ctx.lineWidth=Math.max(1.5,cell*.07);ctx.lineCap='round';
ctx.beginPath();ctx.moveTo(cx,cy-r*.9);ctx.quadraticCurveTo(cx+r*.1,cy-r*1.25,cx+r*.3,cy-r*1.35);ctx.stroke();
ctx.fillStyle='#7ee06a';ctx.beginPath();ctx.ellipse(cx+r*.55,cy-r*1.25,r*.3,r*.15,-0.5,0,7);ctx.fill();
```
Using 7 (~2π) for end angle in arc/ellipse is fine.
drawSnake:
```
const t=renderT;
const pts=snake.map((s,i)=>{const p=prevSnake[i]||s;return{x:(p.x+(s.x-p.x)*t+.5)*cell,y:(p.y+(s.y-p.y)*t+.5)*cell};});
const d=dir; // current direction for eyes
const isDead=state==='over'||state==='demoDead';
// path
ctx.lineJoin='round';ctx.lineCap='round';
ctx.beginPath();ctx.moveTo(pts[0].x,pts[0].y);
for(let i=1;i<pts.length;i++)ctx.lineTo(pts[i].x,pts[i].y);
// glow outline
ctx.save();
ctx.shadowColor=isDead?'rgba(255,107,94,.4)':'rgba(132,224,120,.45)';
ctx.shadowBlur=cell*.55;
ctx.strokeStyle=isDead?'#5c2a22':'#0d3a24';
ctx.lineWidth=cell*.8;
ctx.stroke();
ctx.restore();
// body gradient
const hp=pts[0], tp=pts[pts.length-1];
let lg=ctx.createLinearGradient(hp.x,hp.y,tp.x,tp.y);
if(isDead){lg.addColorStop(0,'#9a8f5a')...} hmm, dead color: dull olive/grey-green: '#7d8f6a','#54704f','#3c5540'.
else{lg.addColorStop(0,'#9ef04f');lg.addColorStop(.5,'#3ecf74');lg.addColorStop(1,'#149e69');}
ctx.strokeStyle=lg;ctx.lineWidth=cell*.62;ctx.stroke();
// spine shading
ctx.strokeStyle='rgba(8,38,22,.22)';ctx.lineWidth=cell*.18;ctx.stroke();
// head
const hr=cell*.5;
tongue: if(!isDead){const cyc=now%2300; if(cyc<230){ draw tongue from hp along d }}
Draw tongue before head circle.
const hg=ctx.createRadialGradient(hp.x-d.x*hr*.3,hp.y-d.y*hr*.3,hr*.1,hp.x,hp.y,hr);
hg stops isDead? dull: '#c8b273','#8a7a4c' else '#e9ffb0','#a8e856','#57c95e';
ctx.fillStyle=hg;ctx.beginPath();ctx.arc(hp.x,hp.y,hr,0,7);ctx.fill();
// eyes
const pxv=-d.y, pyv=d.x; // perp
const eo=cell*.19, fo=cell*.13, er=cell*.16;
for(const s of[1,-1]){
const ex=hp.x+d.x*fo+pxv*eo*s, ey=hp.y+d.y*fo+pyv*eo*s;
if(isDead){ draw X } else {
ctx.fillStyle='#f6fff1';ctx.beginPath();ctx.arc(ex,ey,er,0,7);ctx.fill();
ctx.fillStyle='#0d2318';ctx.beginPath();ctx.arc(ex+d.x*er*.42,ey+d.y*er*.42,er*.52,0,7);ctx.fill();
ctx.fillStyle='rgba(255,255,255,.9)';ctx.beginPath();ctx.arc(ex+d.x*er*.2-er*.18,ey+d.y*er*.2-er*.25,er*.18,0,7);ctx.fill();
}
}
```
X eyes:
```
ctx.strokeStyle='#132a1c';ctx.lineWidth=Math.max(1.6,cell*.06);
ctx.beginPath();
ctx.moveTo(ex-er*.7,ey-er*.7);ctx.lineTo(ex+er*.7,ey+er*.7);
ctx.moveTo(ex+er*.7,ey-er*.7);ctx.lineTo(ex-er*.7,ey+er*.7);
ctx.stroke();
```
Eye whites for dead: draw pale circle first then X. ok: draw circle '#dfe8d8' then X.
Length 1 snake never happens (start length 4).
Tongue draw:
```
const tipX=hp.x+d.x*hr*1.5? Let's do: start at hp+d*hr*.9; end at start+d*cell*.5; fork at end ±perp*cell*.12 back.
ctx.strokeStyle='#ff5d7a';ctx.lineWidth=Math.max(1.5,cell*.07);ctx.lineCap='round';
const sx=hp.x+d.x*hr*.95, sy=hp.y+d.y*hr*.95;
const ex2=sx+d.x*cell*.5, ey2=sy+d.y*cell*.5;
ctx.beginPath();ctx.moveTo(sx,sy);ctx.lineTo(ex2,ey2);
ctx.lineTo(ex2+(d.x*.5+pxv*.9)*cell*.14, ey2+(d.y*.5+pyv*.9)*cell*.14);
ctx.moveTo(ex2,ey2);
ctx.lineTo(ex2+(d.x*.5-pxv*.9)*cell*.14, ey2+(d.y*.5-pyv*.9)*cell*.14);
ctx.stroke();
```
ok.
drawParticles:
```
ctx.save();ctx.globalCompositeOperation='lighter';
for(const p of particles){const a=1-p.t/p.dur;ctx.globalAlpha=Math.max(0,a);ctx.fillStyle=p.col;ctx.beginPath();ctx.arc(p.x,p.y,p.r*(0.5+a*.5),0,7);ctx.fill();}
ctx.restore();ctx.globalAlpha=1;
```
burst:
```
function burst(x,y,cols,n,spd){
if(RM)n=Math.min(n,8);
for(let i=0;i<n;i++){
const a=Math.random()*Math.PI*2, v=(0.35+Math.random()*.9)*spd*cell/12;
```
hmm units: I want px/ms. Cell ~22px; speed about 0.05–0.2 px/ms. Let v=(.04+Math.random()*.14)*(spd/0.28)? Simpler: v=(0.05+Math.random()*0.16); scale by spd param (cell-independent). Actually let's scale by cell/22 for consistency: v=(0.05+Math.random()*.15)*(cell/22)*(spd/0.28). Fine.
```
particles.push({x,y,vx:Math.cos(a)*v,vy:Math.sin(a)*v-cell*.002,r:(1.2+Math.random()*2.4)*(cell/22),t:0,dur:420+Math.random()*380,col:cols[(Math.random()*cols.length)|0]});
}
}
```
updateFX: p.t+=dt; p.x+=p.vx*dt; p.y+=p.vy*dt; p.vy+=0.0007*dt; remove if t>dur.
Floaters:
```
for(const f of floaters){f.t+=dt;}
draw: const k=f.t/f.dur; alpha=k<.7?1:1-(k-.7)/.3; y=f.y-cell*(.4+1.1*easeOut(k)); text.
ctx.font=`800 ${Math.max(12,cell*.5)}px ${mono-ish}`; use "700 Xpx ui-monospace, monospace". Fill amber with slight shadow.
```
drawFlies:
```
bctx.clearRect(0,0,bw,bh);
if(RM)return;
bctx.save();bctx.globalCompositeOperation='lighter';
for(const f of flies){
f.y-=f.s*dt/1000; f.x+=Math.sin(now/1500+f.ph)*.00004*dt;
if(f.y<-0.04){f.y=1.04;f.x=Math.random();}
const a=.10+.12*(0.5+0.5*Math.sin(now/900+f.ph));
bctx.fillStyle=f.warm?`rgba(251,191,36,${a})`:`rgba(134,239,172,${a})`;
bctx.beginPath();bctx.arc(f.x*bw,f.y*bh,f.r*(dpr>1.5?1.4:1),0,7);bctx.fill();
}
bctx.restore();
```
Radius independent of dpr, since transform set. ok.
Init: fit() manually once too (RO fires but double-safe), reset() with state='demo', showOverlay('demo'), requestAnimationFrame(frame). last=performance.now() set beforehand.
Potential issue: on init reset() calls setScoreChip → scoreEl exists. Fine.
Mute indicator element: <span id="snd">SOUND ON</span>? Start with "SOUND ON" but no audio until gesture; label is fine. Toggle on M.
Best chip flash class: @keyframes flashBest: amber box-shadow pulse. Add class then remove after animationend or just leave; remove before adding again: bestChip.classList.remove('flash'); void offsetWidth; add.
Pause timing: on resume, dt clamp handles; acc continues. renderT too: while paused, running false → renderT holds.
One subtle: while paused, shake/particles continue updating (updateFX always) — fine, nice.
Also on 'over', snake static with renderT frozen — but prevSnake may differ; renderT value at death ≈ small → static drawn near prev cell: good enough. Head remains at last safe cell. Red burst at collision point.
Restart with Space from over — preventDefault Space globally so no page scroll anyway.
Prevent Enter default too (button activation) — done.
Check: KEYDIR uses e.code — works with WASD regardless of layout. Arrows too.
e.preventDefault on arrows globally — also while typing? No input fields exist. Fine.
Sizing: .frame width min(96vw,640px). Board inside bezel: bezel width 100%, aspect-ratio 1/1 → inner canvas ~620px max. At 1280×800: header ~70 + gap + board 620 + footer ~40 + padding ≈ 780 — slightly tight against 800 viewport height with browser chrome... iframe is 1280×800 content. Total: padding top 22 + header (~76) + 14 + bezel (620+20 padding + border) + 14 + footer 24 + 26 ≈ 796. Just over 800 → maybe body flex center with scroll? overflow-x hidden only; vertical overflow will show scrollbar inside iframe if content > 800. Let's constrain bezel width with height also: `.bezel{width:min(100%, calc(100vh - 220px));}` and frame centers it. margin-inline auto on bezel. Then at 800 tall: board ≤ 580. Total ~736 fits. On mobile 375 wide: width min → 96vw=360 dominates; height calc fine.
Set .bezel{ width:min(100%, 620px, calc(100vh - 230px)); margin:0 auto; aspect-ratio:1; }. frame width min(96vw, 660px).
Also body align center; if content taller than viewport (tiny landscape phone?) — media check: on short landscape 375? 375 wide usually portrait (~667 tall) fine: board= min(360, 667-230=437)=360 ok.
Landscape short (e.g., 740×360): board=min(100%≤660,360-230=130) tiny 130px... For extremely short viewport it's tolerable, edge case. Maybe calc(100vh - 190px) and clamp min 240px: use max(240px, calc(100vh - 210px))? If board 240 + header/footer → overflow scroll; acceptable fallback. Let's do width:min(100%, 620px, max(250px, calc(100vh - 215px))). CSS max/min well-supported.
Header responsive: at ≤480px, .brand h1 smaller, chips smaller; keep row (wrap).
Now let me finalize overlay CSS classes: .overlay h2 etc.
.ov-title uses var(--display=--mono) 800 weight, size clamp(26px,7vw,46px), letter-spacing .16em. Add slight text indent for spacing? Fine.
.ov-score: font mono 800 clamp(34px,8vw,52px) color amber glow.
.ov-meta: 12px letterspacing .18em muted.
.newbest: inline-block amber bg dark text padding 2px 8px radius 4px margin-left 6px animation pop.
.ov-badge: font 10px letterspacing .35em color #5eead4 border 1px rgba(94,234,212,.4) padding 5px 12px radius 999px background rgba(94,234,212,.06).
.ov-prompt: mono 700 12-13px letterspacing .22em color #d9f99d animation blink 1.3s infinite. margin-top 6px.
.ov-sub: 12px letterspacing .12em muted.
Overlay gap: 12px; padding 20px.
kbd footer: display flex wrap gap 10px center; font mono 11px letterspacing .08em color muted; separators "·".
Also add hidden hint for touch: `<span class="touch-hint"> · SWIPE TO STEER</span>` shown only on coarse pointer via media (pointer:coarse).
Chip markup:
```
<div class="scores">
<div class="chip" id="chipScore"><span class="lbl">SCORE</span><span class="val lime" id="score">0</span></div>
<div class="chip" id="chipBest"><span class="lbl">BEST</span><span class="val amber" id="best">0</span></div>
</div>
```
Chip CSS: display flex column align center gap 2px; border etc.; hover transform.
Pop keyframes:
@keyframes pop{0%{transform:scale(1)}35%{transform:scale(1.28)}100%{transform:scale(1)}} .val.pop{animation:pop .3s ease} .val display inline-block for transform.
Flash keyframes: box-shadow 0 0 0 0 rgba(251,191,36,.7) → 0 0 0 14px transparent; .flash{animation:flash .8s ease}.
Entrance: .frame>*{animation:rise .6s cubic-bezier(.2,.7,.2,1) both} nth-child delays.
@keyframes rise{from{opacity:0;transform:translateY(16px)}}.
Mark bob: translateY ±2px 4s.
Title glow pulse: textshadow subtle animation? .ov-title? Skip maybe to reduce noise; add slight glow on h1 static.
prefers-reduced-motion: also disable entrance animations via media query: @media (prefers-reduced-motion: reduce){*{animation:none!important;transition:none!important}}.
Now double-check "consecutive rapid key presses both honored" — queue ≤3 and applied one per tick ✔️. And reversal prevention validated against last queued ✔️.
Also snake never reverses even in demo: AI candidates exclude back direction by construction (straight/left/right only) ✔️.
Demo overlay persists on demo death (state demoDead) ✔️ and resets after 1s.
One more: after startGame from demo click, overlay hidden; overlay classList hidden remains until next showOverlay removes it. showOverlay removes hidden ✔️.
Sound indicator element update:
```
const sndEl=document.getElementById('snd');
if(c==='KeyM'){muted=!muted;sndEl.textContent=muted?'SOUND OFF':'SOUND ON';sndEl.classList.toggle('dim',muted);}
```
Also add aria-label on canvas.
Recheck drawSnake when snake length 4 and pts[0].. fine.
Note: ctx.ellipse and arc end angle 7 is ok (>2π).
Font string for floaters: `800 ${px}px ui-monospace, Menlo, Consolas, monospace`.
Also fillStyle/textAlign center/strokeText dark for readability.
Compute drawBoard corner brackets:
```
const cl=Math.max(10,b*.035), o=3;
ctx.beginPath();
ctx.moveTo(o,o+cl);ctx.lineTo(o,o);ctx.lineTo(o+cl,o);
ctx.moveTo(b-o-cl,o);ctx.lineTo(b-o,o);ctx.lineTo(b-o,o+cl);
ctx.moveTo(b-o,b-o-cl);ctx.lineTo(b-o,b-o);ctx.lineTo(b-o-cl,b-o);
ctx.moveTo(o+cl,b-o);ctx.lineTo(o,b-o);ctx.lineTo(o,b-o-cl);
ctx.stroke();
```
OK, initial 'won' overlay template too:
won: title "BOARD CLEARED!" score etc, prompt same. Rare anyway.
Let me make sure no console errors: guard actx null in tone. ResizeObserver available. matchMedia ok.
Performance: per frame: cells 288 rects + gradient ok.
Also add subtle trail behind snake? Not needed.
Another polish: score chip bump on eat handled by pop; food spawn pop handled.
Let me write out the final file now, carefully. Also make sure the overlay z-index above scanline? scanline ::after z? Set ::after z-index:2 and overlay 3.
Overlay is inside bezel; canvas is child; overlay is absolutely positioned sibling.
Bezel position relative.
Potential CSS pitfall: aspect-ratio + width min with calc vh — fine.
I'll write the JS in full and compile it mentally.
Also `new ResizeObserver(fit).observe(cvs)` — fit uses cvs.clientWidth; initially layout not ready? Script at end of body → layout ready. RO fires initial callback asynchronously → fit runs. Also to be safe call fit() immediately once: fit() before observe; RO updates later. cvs.clientWidth available. But CSS `aspect-ratio` sizing on canvas: canvas element CSS width 100% height 100% inside bezel content box; bezel width computed → yes.
Edge: font-size in template overlay uses clamp with vw — fine.
Now final code. Aim for clean and complete. Nothing external.
Header tagline: "GARDEN ARCADE — 24 × 24 GRID".
Footer:
```
<footer class="hints">
<span><kbd>↑↓←→</kbd> / <kbd>WASD</kbd> MOVE</span><i>·</i>
<span><kbd>P</kbd> PAUSE</span><i>·</i>
<span><kbd>SPACE</kbd> RESTART</span><i>·</i>
<span><kbd>M</kbd> <span id="snd">SOUND ON</span></span>
<span class="touch-hint">· SWIPE TO STEER</span>
</footer>
```
i styled as muted separator, font-style normal.
kbd arrows: "↑ ↓ ← →" with spaces? Use "↑↓←→".
Media (pointer:coarse){.touch-hint{display:inline}} default none.
Now let me write it all out. Careful with the template literal in showOverlay containing ${}.
Also note: in die() in demo branch set state='demoDead'; in playing branch set state='over'. Implement:
```
function die(nx,ny){
const cx=((nx??snake[0].x)+.5)*cell, cy=((ny??snake[0].y)+.5)*cell;
burst(cx,cy,['#ff6b5e','#ffb454','#ff8f7a','#ffe3c2'],26,.34);
shake=RM?0:1;
if(state==='demo'){
state='demoDead';
clearTimeout(demoTimer);
demoTimer=setTimeout(()=>{ if(state==='demoDead'){ reset(); state='demo'; showOverlay('demo'); } },900);
}else{
state='over';
if(score>best){ best=score; newBestFlag=true; bestEl.textContent=best; popChip(bestChip); }
showOverlay('over');
sDie();
}
}
```
nx may be -1 (into wall) → burst outside board edge partially — actually dramatic and nice. Clamp within board: nx===-1 → -0.5 cell center → -0.5*cell+0.5*cell=0 → edge. Visually ok (burst half clipped). ok.
newBestFlag reset in startGame.
showOverlay('over') uses newBestFlag.
Chip pop for best: reuse pop on .val? Add class on chip for border flash. Implementation: bestChip.classList.remove('flash'); void reflow; add.
updateFX removes arrays:
```
particles=particles.filter(p=>(p.t+=dt)<p.dur);
```
But update positions before filter: do in loop.
```
for(const p of particles){p.t+=dt;p.x+=p.vx*dt;p.y+=p.vy*dt;p.vy+=0.0007*dt;}
particles=particles.filter(p=>p.t<p.dur);
Same for floaters.
```
drawFloaters:
```
ctx.textAlign='center';
for(const f of floaters){
const k=f.t/f.dur, a=k<.6?1:1-(k-.6)/.4;
const y=f.y-cell*(.3+1.3*(1-Math.pow(1-k,2)));
ctx.font=`800 ${Math.round(Math.max(12,cell*.48))}px ui-monospace,Menlo,Consolas,monospace`;
ctx.globalAlpha=a;
ctx.fillStyle='#ffd76a';
ctx.strokeStyle='rgba(0,0,0,.5)';ctx.lineWidth=3;
ctx.strokeText(f.text,f.x,y);ctx.fillText(f.text,f.x,y);
}
ctx.globalAlpha=1;
```
OK. Let me compile everything into the final answer. Careful with backticks inside template strings (overlay uses them; no backticks in the floater font string — fine).
One more check: `state==='won'` handling in keydown restart branch ✔️; overlay click ✔️.
eat() when food null then → finish: after spawnFood, if(!food){ state='won'; if(score>best){...}; showOverlay('won'); return; } put in eat.
Also step check food&& before compare ✔️.
aiChoose guard if(!food)return dir.
I think I'm ready. Also include a small "speed" display? Skip.
Header brand tag: "TERMINAL GARDEN · 24×24" cute.
Now let me write the final code block. Careful: within <style>, body::before mask-image needs -webkit prefix for Chrome? Chrome supports unprefixed mask-image now (since 120 supports standard). Include both to be safe.
Also verify no horizontal scrollbar: body overflow-x hidden; frame ≤96vw ✔️.
Font weights: mono 800 may synthesize; fine.
Chip: .val font-size clamp(20px,3.4vw,28px).
Bezel padding on small screens shrink to 8px: media 480px.
Now the final code.