GLM 5.3 Flash
by Z.aiReasoningClean
Running live in a sandboxed frame. Click inside it before using the keyboard.
How it went
- Total time
- 5 min 45 s
- Cost
- $0.014
- Output tokens
- 28,469
- Spent thinking
- 21,007 tokens
- Speed
- 82.7 tok/s
- Console errors
- 0
- First token after
- 636 ms
- First visible text after
- 4 min 47 s
- Prompt tokens
- 491
- Code size
- 22.1 KB
- Finish reason
- stop
- Served by
- DeepInfra
- Model id
z-ai/glm-5.3-flash- Reasoning sent
{"effort":"high"}- Max output
- 64,000 tokens
- Generated
- Nov 10, 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 · 802 lines
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Snake</title>
<style>
:root{
--paper:#EDE3D1;
--ink:#1F1D19;
--board:#221F1B;
--coral:#E4572E;
--coral-deep:#D8431F;
--ivory:#F3EBDA;
--muted:#8B8170;
--mono:ui-monospace,'Cascadia Mono','SF Mono',Menlo,Consolas,monospace;
--serif:Georgia,'Iowan Old Style','Times New Roman',serif;
}
*{box-sizing:border-box}
html,body{height:100%}
body{
margin:0;
background:var(--paper);
color:var(--ink);
font-family:var(--serif);
display:grid;
place-items:center;
overflow:hidden;
-webkit-font-smoothing:antialiased;
}
.wrap{
display:flex;
flex-direction:column;
align-items:center;
gap:14px;
width:100%;
max-width:566px;
padding:16px 12px;
}
/* ---------- HUD ---------- */
.hud{
width:100%;
display:flex;
justify-content:space-between;
align-items:flex-end;
}
.brand h1{
margin:0;
font-size:clamp(22px,6vw,30px);
font-weight:700;
letter-spacing:.4em;
line-height:1;
white-space:nowrap;
}
.pip{
display:inline-block;
width:11px;height:11px;
background:var(--coral);
border-radius:3px;
transform:rotate(45deg);
margin-right:14px;
vertical-align:1px;
}
.brand p{
margin:6px 0 0;
font-family:var(--mono);
font-size:10px;
letter-spacing:.22em;
text-transform:uppercase;
color:var(--muted);
}
.stats{display:flex;gap:22px}
.stat{text-align:right}
.stat .label{
display:block;
font-family:var(--mono);
font-size:10px;
letter-spacing:.24em;
text-transform:uppercase;
color:var(--muted);
margin-bottom:2px;
}
.stat .value{
display:inline-block;
font-family:var(--mono);
font-weight:700;
font-size:26px;
line-height:1;
color:var(--ink);
font-variant-numeric:tabular-nums;
}
.stat .value.coral{color:var(--coral-deep)}
.stat .value.pop{animation:pop .32s ease}
@keyframes pop{40%{transform:scale(1.35)}}
/* ---------- Board ---------- */
.stage{
position:relative;
width:min(546px, 92vw, calc(100vh - 215px));
width:min(546px, 92vw, calc(100svh - 215px));
aspect-ratio:1/1;
}
canvas{
display:block;
width:100%;
height:100%;
background:var(--board);
border:3px solid var(--ink);
border-radius:10px;
box-shadow:8px 8px 0 rgba(31,29,25,.16);
touch-action:none;
}
/* ---------- Overlay ---------- */
.overlay{
position:absolute;
inset:3px;
border-radius:8px;
background:rgba(23,22,19,.8);
display:flex;
align-items:center;
justify-content:center;
text-align:center;
opacity:0;
visibility:hidden;
transition:opacity .35s ease, visibility .35s ease;
cursor:pointer;
padding:16px;
}
.overlay.show{opacity:1;visibility:visible}
.ov-kicker{
margin:0 0 10px;
font-family:var(--mono);
font-size:11px;
letter-spacing:.26em;
text-transform:uppercase;
color:rgba(243,235,218,.62);
}
.ov-title{
margin:0;
font-size:clamp(30px,7vw,46px);
letter-spacing:.18em;
color:var(--ivory);
font-weight:700;
}
.ov-title.doom{color:var(--coral)}
.chip{
display:inline-block;
margin:14px 0 2px;
padding:5px 14px;
background:var(--coral);
color:#FFF6E8;
font-family:var(--mono);
font-size:11px;
letter-spacing:.22em;
text-transform:uppercase;
border-radius:999px;
}
.ov-stats{
display:flex;
justify-content:center;
gap:34px;
margin-top:16px;
}
.ov-stats span{
display:block;
font-family:var(--mono);
font-size:10px;
letter-spacing:.24em;
text-transform:uppercase;
color:rgba(243,235,218,.55);
margin-bottom:4px;
}
.ov-stats b{
font-family:var(--mono);
font-size:34px;
font-weight:700;
color:var(--ivory);
font-variant-numeric:tabular-nums;
}
.ov-stats .hot b{color:var(--coral)}
.ov-action{
margin:20px 0 0;
font-family:var(--mono);
font-size:13px;
letter-spacing:.06em;
color:var(--ivory);
animation:breathe 2.2s ease-in-out infinite;
}
@keyframes breathe{0%,100%{opacity:1}50%{opacity:.55}}
.ov-sub{
margin:14px 0 0;
font-family:var(--mono);
font-size:12px;
color:rgba(243,235,218,.7);
}
.ov-sub em{font-style:normal;color:rgba(243,235,218,.45);margin:0 4px}
.overlay kbd{
font-family:var(--mono);
font-size:11px;
color:var(--ivory);
background:rgba(243,235,218,.08);
border:1px solid rgba(243,235,218,.35);
border-bottom-width:3px;
border-radius:6px;
padding:2px 7px;
margin:0 2px;
}
/* ---------- Footer hints ---------- */
.hints{
display:flex;
flex-wrap:wrap;
justify-content:center;
align-items:center;
gap:8px 20px;
font-family:var(--mono);
font-size:12px;
color:var(--muted);
}
.hints .group{display:flex;align-items:center;gap:5px}
.hints kbd{
font-family:var(--mono);
font-size:11px;
color:var(--ink);
background:rgba(31,29,25,.05);
border:1px solid rgba(31,29,25,.45);
border-bottom-width:3px;
border-radius:6px;
padding:2px 7px;
}
.hints em{font-style:italic;color:rgba(139,129,112,.8);margin:0 2px}
</style>
</head>
<body>
<div class="wrap">
<header class="hud">
<div class="brand">
<h1><i class="pip"></i>SNAKE</h1>
<p>21 × 21 · smooth classic</p>
</div>
<div class="stats">
<div class="stat"><span class="label">Score</span><span class="value coral" id="score">0</span></div>
<div class="stat"><span class="label">Best</span><span class="value" id="best">0</span></div>
</div>
</header>
<div class="stage">
<canvas id="cv"></canvas>
<div id="overlay" class="overlay"></div>
</div>
<footer class="hints">
<span class="group"><kbd>↑</kbd><kbd>←</kbd><kbd>↓</kbd><kbd>→</kbd><em>or</em><kbd>W</kbd><kbd>A</kbd><kbd>S</kbd><kbd>D</kbd><span>steer</span></span>
<span class="group"><kbd>P</kbd><span>pause</span></span>
</footer>
</div>
<script>
(() => {
'use strict';
/* ---------- constants ---------- */
const COLS = 21, ROWS = 21;
const UP = {x:0,y:-1}, DOWN = {x:0,y:1}, LEFT = {x:-1,y:0}, RIGHT = {x:1,y:0};
const KEY_DIR = {
ArrowUp:UP, KeyW:UP, ArrowDown:DOWN, KeyS:DOWN,
ArrowLeft:LEFT, KeyA:LEFT, ArrowRight:RIGHT, KeyD:RIGHT
};
const C = {
board:'#221F1B', cellB:'#272420',
body:'#E4572E', dead:'#F3EBDA', head:'#C8431F',
spine:'rgba(255,219,190,0.42)',
ivory:'#F3EBDA', ink:'#171512', tongue:'#FF6B45'
};
/* ---------- dom ---------- */
const cv = document.getElementById('cv');
const ctx = cv.getContext('2d');
const overlay = document.getElementById('overlay');
const scoreEl = document.getElementById('score');
const bestEl = document.getElementById('best');
/* ---------- state ---------- */
let mode = 'demo'; // demo | play | paused | over
let snake = [], prevSnake = [];
let dir = RIGHT, dirQueue = [];
let food = null;
let score = 0, best = 0, bestBeforeRun = 0, newBest = false;
let tickMs = 130, acc = 0;
let particles = [], floaters = [], rings = [];
let dying = false, deathAt = 0;
let overShown = false;
let demoTimer = null;
let userGestured = false;
let lastFrame = performance.now();
/* ---------- audio (tiny, created only after user gesture) ---------- */
let actx = null;
function blip(f0, f1, dur, type, vol){
if(!userGestured) return;
try{
actx = actx || new (window.AudioContext || window.webkitAudioContext)();
if(actx.state === 'suspended') actx.resume();
const t = actx.currentTime;
const o = actx.createOscillator(), g = actx.createGain();
o.type = type || 'triangle';
o.frequency.setValueAtTime(f0, t);
if(f1) o.frequency.exponentialRampToValueAtTime(f1, t + dur);
g.gain.setValueAtTime(vol || 0.05, t);
g.gain.exponentialRampToValueAtTime(0.0001, t + dur);
o.connect(g); g.connect(actx.destination);
o.start(t); o.stop(t + dur + 0.02);
}catch(err){}
}
/* ---------- helpers ---------- */
function hexToRgb(h){
return [parseInt(h.slice(1,3),16), parseInt(h.slice(3,5),16), parseInt(h.slice(5,7),16)];
}
function mix(a, b, k){
const A = hexToRgb(a), B = hexToRgb(b);
return 'rgb(' + A.map((v,i)=>Math.round(v+(B[i]-v)*k)).join(',') + ')';
}
function fit(){
const r = cv.getBoundingClientRect();
const dpr = Math.min(2, window.devicePixelRatio || 1);
const px = Math.max(300, Math.round(r.width * dpr));
if(cv.width !== px || cv.height !== px){ cv.width = cv.height = px; }
}
/* ---------- game core ---------- */
function resetGame(){
const cx0 = Math.floor(COLS/2), cy0 = Math.floor(ROWS/2);
snake = [];
for(let i=0;i<4;i++) snake.push({x:cx0-i, y:cy0});
prevSnake = snake.map(p=>({x:p.x, y:p.y}));
dir = RIGHT; dirQueue = [];
score = 0; bestBeforeRun = best; newBest = false;
tickMs = 130; acc = 0;
particles = []; floaters = []; rings = [];
dying = false;
spawnFood();
updateHUD(false);
}
function spawnFood(){
const occ = new Set(snake.map(p=>p.x+','+p.y));
const free = [];
for(let y=0;y<ROWS;y++)
for(let x=0;x<COLS;x++)
if(!occ.has(x+','+y)) free.push({x,y});
if(!free.length){ food = null; win(); return; }
food = free[(Math.random()*free.length)|0];
}
function hitsBody(x, y){
for(let i=0;i<snake.length-1;i++)
if(snake[i].x===x && snake[i].y===y) return true;
return false;
}
function queueDir(d){
const last = dirQueue.length ? dirQueue[dirQueue.length-1] : dir;
if(d.x===last.x && d.y===last.y) return;
if(d.x===-last.x && d.y===-last.y) return; // never reverse
if(dirQueue.length < 3) dirQueue.push(d); // rapid presses are honored one per tick
}
/* ---------- demo AI ---------- */
function aiSteer(){
const h = snake[0];
const all = [UP, DOWN, LEFT, RIGHT];
const opts = [];
for(const d of all){
if(d.x===-dir.x && d.y===-dir.y) continue;
const nx = h.x+d.x, ny = h.y+d.y;
if(nx<0||ny<0||nx>=COLS||ny>=ROWS) continue;
if(hitsBody(nx,ny)) continue;
let freedom = 0;
for(const e of all){
const ax = nx+e.x, ay = ny+e.y;
if(ax<0||ay<0||ax>=COLS||ay>=ROWS) continue;
if(!hitsBody(ax,ay)) freedom++;
}
const dist = Math.abs(nx-food.x) + Math.abs(ny-food.y);
opts.push({d, dist, freedom});
}
if(!opts.length) return; // trapped — demo will restart
opts.sort((a,b)=>(a.dist-b.dist) || (b.freedom-a.freedom));
dirQueue = [opts[0].d];
}
/* ---------- tick ---------- */
function tick(){
if(dying) return;
if(mode==='demo') aiSteer();
while(dirQueue.length){
const d = dirQueue.shift();
if(d.x===-dir.x && d.y===-dir.y) continue;
if(d.x===dir.x && d.y===dir.y) continue;
dir = d; break;
}
prevSnake = snake.map(p=>({x:p.x, y:p.y}));
const h = snake[0], nx = h.x+dir.x, ny = h.y+dir.y;
if(nx<0 || ny<0 || nx>=COLS || ny>=ROWS){ die(); return; }
if(hitsBody(nx, ny)){ die(); return; }
snake.unshift({x:nx, y:ny});
if(food && nx===food.x && ny===food.y){
score += 10;
if(score > best) best = score;
updateHUD(true);
burst(nx, ny);
tickMs = Math.max(74, tickMs - 2.4);
blip(620, 980, 0.09, 'square', 0.045);
spawnFood();
} else {
snake.pop();
}
}
function die(){
dying = true;
deathAt = performance.now();
blip(220, 85, 0.32, 'sawtooth', 0.06);
if(mode === 'play'){
mode = 'over';
overShown = false;
newBest = score > bestBeforeRun && score > 0;
setTimeout(()=>{
if(mode==='over'){ showOver(false); overShown = true; }
}, 620);
} else {
// demo: silently restart after a beat
clearTimeout(demoTimer);
demoTimer = setTimeout(()=>{ if(mode==='demo') resetGame(); }, 900);
}
}
function win(){
dying = true;
deathAt = performance.now();
mode = 'over';
overShown = false;
newBest = score > bestBeforeRun && score > 0;
setTimeout(()=>{
if(mode==='over'){ showOver(true); overShown = true; }
}, 620);
}
/* ---------- fx ---------- */
function burst(x, y){
const cx = x+0.5, cy = y+0.5;
for(let i=0;i<16;i++){
const a = Math.random()*Math.PI*2, sp = 2 + Math.random()*3.6;
particles.push({
x:cx, y:cy,
vx:Math.cos(a)*sp, vy:Math.sin(a)*sp - 1.4,
life:0, max:0.45 + Math.random()*0.4,
r:0.05 + Math.random()*0.08,
c: Math.random()<0.55 ? '#F3EBDA' : '#FF7A4D',
streak: Math.random()<0.35
});
}
rings.push({x:cx, y:cy, t:0});
floaters.push({x:cx, y:cy-0.3, t:0});
}
function updateFx(dt){
for(const p of particles){
p.life += dt;
p.x += p.vx*dt; p.y += p.vy*dt;
p.vy += 6.5*dt;
p.vx *= Math.max(0, 1 - 1.4*dt);
}
particles = particles.filter(p=>p.life < p.max);
for(const r of rings) r.t += dt;
rings = rings.filter(r=>r.t < 0.45);
for(const f of floaters) f.t += dt;
floaters = floaters.filter(f=>f.t < 0.9);
}
/* ---------- hud ---------- */
function updateHUD(pop){
scoreEl.textContent = score;
bestEl.textContent = best;
if(pop){
scoreEl.classList.remove('pop');
void scoreEl.offsetWidth;
scoreEl.classList.add('pop');
}
}
/* ---------- overlays ---------- */
const kbdRow = '<kbd>↑</kbd><kbd>←</kbd><kbd>↓</kbd><kbd>→</kbd><em>or</em><kbd>W</kbd><kbd>A</kbd><kbd>S</kbd><kbd>D</kbd>';
function showReady(){
overlay.innerHTML =
'<div class="ov-inner">' +
'<p class="ov-kicker">demo · the snake plays itself</p>' +
'<h2 class="ov-title">SNAKE</h2>' +
'<p class="ov-action">Click or press a key to start</p>' +
'<p class="ov-sub">' + kbdRow + ' steer · <kbd>P</kbd> pause</p>' +
'</div>';
overlay.classList.add('show');
}
function showPause(){
overlay.innerHTML =
'<div class="ov-inner">' +
'<h2 class="ov-title">PAUSED</h2>' +
'<p class="ov-action">Press <kbd>P</kbd> to resume</p>' +
'</div>';
overlay.classList.add('show');
}
function showOver(won){
overlay.innerHTML =
'<div class="ov-inner">' +
'<h2 class="ov-title' + (won?'':' doom') + '">' + (won ? 'PERFECT!' : 'GAME OVER') + '</h2>' +
(newBest ? '<span class="chip">New best</span>' : '') +
'<div class="ov-stats">' +
'<div class="hot"><span>Score</span><b>' + score + '</b></div>' +
'<div><span>Best</span><b>' + best + '</b></div>' +
'</div>' +
'<p class="ov-action">Space or Enter to play again</p>' +
'</div>';
overlay.classList.add('show');
}
function hideOverlay(){ overlay.classList.remove('show'); }
/* ---------- flow ---------- */
function startPlay(d){
clearTimeout(demoTimer);
userGestured = true;
resetGame();
mode = 'play';
hideOverlay();
if(d) queueDir(d);
blip(520, 760, 0.12, 'triangle', 0.04);
}
window.addEventListener('keydown', (e)=>{
const d = KEY_DIR[e.code];
if(d || e.code==='Space' || e.code==='Enter') e.preventDefault();
if(e.code === 'KeyP'){
if(mode==='play'){ mode='paused'; showPause(); }
else if(mode==='paused'){ mode='play'; hideOverlay(); }
return;
}
if(mode==='demo'){ startPlay(d || null); return; }
if(mode==='play'){ if(d) queueDir(d); return; }
if(mode==='paused'){ mode='play'; hideOverlay(); return; }
if(mode==='over' && overShown && (e.code==='Space' || e.code==='Enter')) startPlay();
});
overlay.addEventListener('click', ()=>{
if(mode==='demo') startPlay();
else if(mode==='paused'){ mode='play'; hideOverlay(); }
else if(mode==='over' && overShown) startPlay();
});
/* touch: swipe to steer, tap to start / resume / restart */
let touchStart = null;
cv.addEventListener('touchstart', (e)=>{
const t = e.changedTouches[0];
touchStart = {x:t.clientX, y:t.clientY};
}, {passive:true});
cv.addEventListener('touchend', (e)=>{
if(!touchStart) return;
const t = e.changedTouches[0];
const dx = t.clientX - touchStart.x, dy = t.clientY - touchStart.y;
touchStart = null;
const adx = Math.abs(dx), ady = Math.abs(dy);
if(Math.max(adx, ady) < 24){
if(mode==='demo') startPlay();
else if(mode==='paused'){ mode='play'; hideOverlay(); }
else if(mode==='over' && overShown) startPlay();
return;
}
if(mode!=='play') return;
queueDir(adx>ady ? (dx>0?RIGHT:LEFT) : (dy>0?DOWN:UP));
}, {passive:true});
/* ---------- drawing ---------- */
function drawFood(now, S){
if(!food) return;
const fx = (food.x+0.5)*S, fy = (food.y+0.5)*S;
const ph = (now % 1100)/1100;
ctx.strokeStyle = 'rgba(243,235,218,' + (0.4*(1-ph)).toFixed(3) + ')';
ctx.lineWidth = Math.max(1, S*0.045);
ctx.beginPath();
ctx.arc(fx, fy, (0.34 + ph*0.4)*S, 0, Math.PI*2);
ctx.stroke();
ctx.fillStyle = C.ivory;
ctx.beginPath();
ctx.arc(fx, fy, (0.27 + 0.025*Math.sin(now/260))*S, 0, Math.PI*2);
ctx.fill();
ctx.fillStyle = 'rgba(255,255,255,0.55)';
ctx.beginPath();
ctx.arc(fx - 0.08*S, fy - 0.09*S, 0.08*S, 0, Math.PI*2);
ctx.fill();
}
function drawSnake(now, tt, S){
const n = snake.length;
if(!n) return;
const pts = new Array(n);
for(let i=0;i<n;i++){
const c = snake[i];
const p = prevSnake[Math.min(i, prevSnake.length-1)] || c;
pts[i] = { x:(p.x + (c.x-p.x)*tt + 0.5)*S, y:(p.y + (c.y-p.y)*tt + 0.5)*S };
}
const flash = dying ? Math.min(1, (now-deathAt)/280) : 0;
const bodyCol = mix(C.body, C.dead, flash*0.85);
const headCol = mix(C.head, C.dead, flash*0.85);
// heading from actual motion (smooth through turns)
let heading = {x:dir.x, y:dir.y};
if(n > 1){
const dx = pts[0].x - pts[1].x, dy = pts[0].y - pts[1].y;
const L = Math.hypot(dx, dy);
if(L > 0.01) heading = {x:dx/L, y:dy/L};
}
// tongue flick (under the head)
if(!dying && (now % 1700) < 240){
const tp = now % 1700;
const k = Math.sin(tp/240 * Math.PI);
const bx = pts[0].x + heading.x*0.28*S, by = pts[0].y + heading.y*0.28*S;
const tx = pts[0].x + heading.x*(0.55 + 0.3*k)*S, ty = pts[0].y + heading.y*(0.55 + 0.3*k)*S;
ctx.strokeStyle = C.tongue;
ctx.lineWidth = Math.max(1.5, S*0.07);
ctx.lineCap = 'round';
ctx.beginPath();
ctx.moveTo(bx, by); ctx.lineTo(tx, ty);
const mx = (bx+tx)/2, my = (by+ty)/2;
ctx.moveTo(mx, my); ctx.lineTo(mx + (heading.x*0.12 - heading.y*0.1)*S, my + (heading.y*0.12 + heading.x*0.1)*S);
ctx.moveTo(mx, my); ctx.lineTo(mx + (heading.x*0.12 + heading.y*0.1)*S, my + (heading.y*0.12 - heading.x*0.1)*S);
ctx.stroke();
}
// tapered body: segment strokes from tail to head
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
for(let i=n-2;i>=0;i--){
const w = (0.72 - 0.30*(i/Math.max(1, n-1)))*S;
ctx.strokeStyle = bodyCol;
ctx.lineWidth = w;
ctx.beginPath();
ctx.moveTo(pts[i+1].x, pts[i+1].y);
ctx.lineTo(pts[i].x, pts[i].y);
ctx.stroke();
}
// light spine
if(flash < 0.5){
ctx.strokeStyle = C.spine;
ctx.lineWidth = Math.max(1.5, S*0.12);
ctx.beginPath();
ctx.moveTo(pts[0].x, pts[0].y);
for(let i=1;i<n;i++) ctx.lineTo(pts[i].x, pts[i].y);
ctx.stroke();
}
// head
ctx.fillStyle = headCol;
ctx.beginPath();
ctx.arc(pts[0].x, pts[0].y, 0.46*S, 0, Math.PI*2);
ctx.fill();
// eyes facing travel direction
const perp = {x:-heading.y, y:heading.x};
for(const s of [-1, 1]){
const ex = pts[0].x + heading.x*0.13*S + perp.x*0.18*S*s;
const ey = pts[0].y + heading.y*0.13*S + perp.y*0.18*S*s;
if(dying){
ctx.strokeStyle = C.ink;
ctx.lineWidth = Math.max(1.5, S*0.05);
ctx.lineCap = 'round';
const r = 0.09*S;
ctx.beginPath();
ctx.moveTo(ex-r, ey-r); ctx.lineTo(ex+r, ey+r);
ctx.moveTo(ex+r, ey-r); ctx.lineTo(ex-r, ey+r);
ctx.stroke();
} else {
ctx.fillStyle = C.ivory;
ctx.beginPath();
ctx.arc(ex, ey, 0.115*S, 0, Math.PI*2);
ctx.fill();
ctx.fillStyle = C.ink;
ctx.beginPath();
ctx.arc(ex + heading.x*0.045*S, ey + heading.y*0.045*S, 0.055*S, 0, Math.PI*2);
ctx.fill();
}
}
}
function drawFx(S){
for(const p of particles){
const k = p.life/p.max;
ctx.globalAlpha = 1 - k;
if(p.streak){
ctx.strokeStyle = p.c;
ctx.lineWidth = Math.max(1, 0.05*S);
ctx.beginPath();
ctx.moveTo(p.x*S, p.y*S);
ctx.lineTo((p.x - p.vx*0.045)*S, (p.y - p.vy*0.045)*S);
ctx.stroke();
} else {
ctx.fillStyle = p.c;
ctx.beginPath();
ctx.arc(p.x*S, p.y*S, p.r*(1 - k*0.4)*S, 0, Math.PI*2);
ctx.fill();
}
}
for(const r of rings){
const k = r.t/0.45;
ctx.globalAlpha = 0.5*(1 - k);
ctx.strokeStyle = '#F3EBDA';
ctx.lineWidth = Math.max(1, 0.07*S);
ctx.beginPath();
ctx.arc(r.x*S, r.y*S, (0.25 + k*1.6)*S, 0, Math.PI*2);
ctx.stroke();
}
for(const f of floaters){
const k = f.t/0.9;
ctx.globalAlpha = 1 - k;
ctx.fillStyle = '#FFB38A';
ctx.font = '700 ' + Math.round(0.55*S) + 'px ui-monospace, Menlo, Consolas, monospace';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText('+10', f.x*S, (f.y - k*1.1)*S);
}
ctx.globalAlpha = 1;
}
function draw(now, tt){
const S = cv.width/COLS;
// death shake (CSS transform on the whole framed board)
let shk = 0;
if(dying){
const k = 1 - (now - deathAt)/450;
if(k > 0) shk = k*k;
}
if(shk > 0){
const m = 0.5*S*shk;
cv.style.transform = 'translate(' + ((Math.random()*2-1)*m).toFixed(1) + 'px,' + ((Math.random()*2-1)*m).toFixed(1) + 'px)';
} else if(cv.style.transform){
cv.style.transform = '';
}
ctx.setTransform(1, 0, 0, 1, 0, 0);
// checkerboard
ctx.fillStyle = C.board;
ctx.fillRect(0, 0, cv.width, cv.height);
ctx.fillStyle = C.cellB;
for(let y=0;y<ROWS;y++)
for(let x=0;x<COLS;x++)
if((x+y)&1) ctx.fillRect(x*S - 0.5, y*S - 0.5, S + 1, S + 1);
drawFood(now, S);
drawSnake(now, tt, S);
drawFx(S);
// wall frame
ctx.strokeStyle = 'rgba(243,235,218,0.14)';
ctx.lineWidth = 2;
ctx.strokeRect(1, 1, cv.width-2, cv.height-2);
}
/* ---------- main loop ---------- */
function frame(now){
requestAnimationFrame(frame);
fit();
let dt = (now - lastFrame)/1000;
lastFrame = now;
if(dt > 0.05) dt = 0.05;
if(mode==='play' || mode==='demo'){
acc += dt*1000;
let steps = 0;
while(acc >= tickMs && steps < 4){
acc -= tickMs;
tick();
steps++;
if(mode!=='play' && mode!=='demo'){ acc = 0; break; }
if(dying){ acc = 0; break; }
}
}
updateFx(dt);
const tt = Math.max(0, Math.min(1, acc/tickMs));
draw(now, tt);
}
/* ---------- go ---------- */
resetGame();
showReady();
requestAnimationFrame(frame);
})();
</script>
</body>
</html>Reasoning trace (21,007 tokens)
Let me plan the Snake game carefully. This is a classic, and the danger is producing a generic, forgettable version. I want it to feel authored — a specific visual identity, smooth interpolation between grid steps, real juice on eating, and a proper demo mode so the game plays itself behind the start overlay.
**Visual identity.** I'll avoid the default neon-green-on-black snake clone look and I won't reach for gradients or glow. Instead I'll go for a warm, paper-like board — think a vintage handheld LCD meets a printed game board. Let me pick a palette:
- Page background: deep warm charcoal/ink, like `#161412` — no, actually let me go lighter and more distinct. A warm cream/paper page with an ink-dark board could be striking: cream `#EFE7D8` page, board in deep ink `#1B1B18` with a subtle checker of two very close dark tones, snake in a vivid but non-default color — maybe a warm coral/tangerine `#FF7847`? Or a mustard/olive? Let me think: snake = warm coral-red `#E4572E`, food = a pale mint/cream dot, head slightly darker with cream eyes. Checker cells: `#20201C` and `#242420`. Border frame around board in cream with a hand-drawn feel — double border, like a printed card. Typography: a monospace or serif display. Since I can't load fonts from network (well, CDN is allowed generally but rules say no external fonts unless explicitly allowed — the task doesn't allow them), I'll use a font stack with character: `"Courier New", monospace` feels cheap... Better: use `Georgia, 'Times New Roman', serif` for headings with letter-spacing for a printed-card look, and a monospace stack (`ui-monospace, 'SF Mono', Menlo, Consolas`) for the score digits — tabular numbers matter for scores. Actually a nice combo: serif display title "SNAKE" in wide-tracked caps, mono for numbers. That reads like a printed game card. Good.
**Layout.** Board centered, 21×21 grid (odd size so the snake can start centered). Canvas cell size ~26px → 546px board. Above the board: a header row with title on the left, score / best on the right, all inside a card frame. Below: a small hint line with key symbols (↑←↓→ / WASD, P pause, Space restart). Everything must fit 1280×800 and adapt to 375px: I'll size the canvas responsively — compute cell size from available width via CSS + JS resize handling, or simpler: fixed internal resolution with CSS scaling (`canvas { width: min(92vw, 546px) }` and use devicePixelRatio-aware backing store). I'll do: JS reads a `--cell` computed from `Math.min(26, Math.floor((viewportWidth - padding)/cols))`, sets canvas width/height accordingly, and re-renders. Simpler robust approach: keep logical grid constant, set canvas CSS width via `min(90vw, 60vh, 546px)` and draw at fixed logical size × dPR. Let me do CSS-driven sizing with `aspect-ratio: 1`, and set the backing store once at fixed 672×672 (cell 32) — scaled down by CSS it'll still look crisp enough, but on hi-dpi at 546px CSS from 672 backing it's slightly downscaled — fine, actually crisp. But if viewport is small, upscaling would blur... `min()` prevents upscaling beyond 546. Hmm, at 375px wide, CSS size = ~345px, backing 672 → downscale, crisp. Good, one fixed backing store, CSS scales down. But wait — at very small sizes downscaling canvas can look slightly soft; acceptable. Actually let me do it properly: recompute backing store on resize to match CSS px × dpr, capped. It's not much code. I'll do a `fit()` function on resize.
**Smooth movement.** The snake advances one cell per tick (tick interval ~110ms, slight speed-up as score grows, capped). For smoothness I keep `prevSegments` (positions before last tick) and interpolate each segment between prev and current using `t = (now - lastTick)/tickInterval`, clamped. Draw with rounded joints: I'll draw the snake as a thick polyline with `lineCap/lineJoin = round` through interpolated segment centers — this gives a beautiful continuous snake body rather than disjoint squares. Body color with a subtle two-tone: main stroke in coral, plus a thinner inner stroke in a lighter tone for a "spine" highlight. Head: draw a slightly larger rounded shape at the head position, with two eyes offset perpendicular to direction, pupils shifted toward movement direction. Tongue flick occasionally? A small forked tongue flicking every ~1.5s when moving — nice detail, cheap to draw. I'll add it.
When snake grows, the tail lingers one tick (classic smooth-snake trick: on growth, the interpolated tail stays put for one tick). Implementation: keep arrays `snake` (current cells, head first) and `prevSnake`. On tick: compute newHead; unshift; if not eating, pop tail. prevSnake = old snake array. For interpolation, segment i interpolates from prevSnake[i] (or prevSnake[last] if i >= prevSnake.length — when grown) to snake[i]. When grown, prevSnake is shorter by... wait: unshift adds head, no pop → new length = old+1. prevSnake[i] for i in [0..oldLen-1] maps old segment i to new segment i+1? No — new snake[i] equals old snake[i-1] for i≥1, and new snake[0] = newHead. So interpolating new[i] from prev[i] would make segment i slide from old i position to old i-1 position — that's exactly the crawling effect, correct! For the last new segment (i = newLen-1 = oldLen), prev index doesn't exist; it should stay at old tail position = prevSnake[oldLen-1] = prevSnake[newLen-2]. Since new[newLen-1] = old[oldLen-1] = prev[newLen-2]... wait new snake after growth: [newHead, ...old]. So new[oldLen] = old[oldLen-1] = prev[prevLen-1] where prevLen = oldLen. So interpolate new[i] from prev[min(i, prevLen-1)] → for i = oldLen, prev index oldLen-1 = its own position → tail stays still during growth tick.
But careful with wall/self death: when dying, I'll freeze interpolation at the moment of death — actually nicer: let the head slide into the wall visually a bit then trigger a shake + overlay. I'll implement: on death, set `dying = true`, record death time, do a small screen shake (canvas transform jitter for ~400ms), flash the snake pale, then show overlay. Keep it simple: shake via translate offsets in draw, decay over 450ms.
**Input queue.** Keep a `dirQueue`; each keydown pushes the direction if it's not opposite/equal to the last queued (or current) direction; on each tick, shift one from the queue. This honors rapid successive presses (e.g., up then left within one tick both apply over two ticks) and prevents reversal. Max queue length 3.
**Food.** Random empty cell (build list of free cells, pick one). Food rendered as a pulsing dot: a cream/ivory berry with a tiny leaf? On ink board, food as a soft warm-white orb with a subtle radial pulse ring. Radial pulse ring = stroke circle animating radius/alpha — not a gradient, fine. Or draw the food as a small apple: circle + stem + leaf in the palette. That's charming and on-theme with "printed card". I'll draw a little apple: body `#E85D3A`? That clashes with coral snake... Snake coral `#E4572E`, food could be cream-white `#F3EBDA` orb (like a pearl) — reads instantly against ink. Plus tiny pulse ring. Let me make food an ivory orb with a faint warm core — I'll keep it flat: solid ivory circle + smaller inner circle slightly warmer + pulsing outline ring in ivory at low alpha. Clean.
**Particles on eating.** Burst of ~14 particles from food position: small circles/short lines in ivory + coral, velocity radial, gravity slight, fade & shrink over ~600ms. Also a floating "+10" text that rises and fades. And a subtle "pop" ring expanding. Score number in the HUD does a quick scale pulse (CSS class toggle).
**Overlay.** Start state: "Click or press a key to start" — per requirements, demo mode: an AI plays behind the overlay. Overlay is a translucent ink veil with the title, brief instructions, and the note. On first key/click → start real game (reset). Game over overlay: "GAME OVER", score, best, "Space / Enter — play again · P — pause". Pause overlay: "PAUSED" minimal.
**Demo AI.** Simple but competent: each tick (or each render tick for safety), compute next direction avoiding immediate death: prefer directions reducing Manhattan distance to food among safe ones (not reversing, target cell not wall/self-body-excluding-tail). Add slight lookahead? Keep simple greedy with safety: candidate dirs = [toward food pref order] filtered by (no reverse, in bounds, not colliding with body minus tail-if-not-growing). If none safe, pick any non-reverse safe; if none, it dies — then demo auto-restarts after a moment. That's fine and looks lively. To avoid the AI trapping itself instantly, also avoid moves into cells whose freedom count is 1 if possible (one-step flood check — count free neighbors of the destination). Cheap. Good enough for a demo.
While in demo, HUD shows score updating too; best updates only in real play? I'll let best track any play including demo — simpler, and "best score of the session" is still meaningful. Fine either way; I'll track best across both but reset demo scores on real start.
**Timing.** `requestAnimationFrame` loop; accumulate time; when `now - lastTick >= tickMs`, do tick (catch-up loop, but cap). Interpolation t computed from accumulator. Speed: start 130ms, each food −2ms, min 75ms. Actually start ~120ms feels good; demo can be same.
Pause: `P` toggles paused (only during real play). Space/Enter: on start screen → start; on game-over → restart; during play, Space could also pause? Requirements say Space or Enter restarts (from game over). I'll make Space/Enter during play do nothing (or pause — I'll leave pause to P only to match spec exactly, but Space pausing is harmless... spec: "Space or Enter restarts, P pauses". I'll bind Space/Enter to restart contextually and ignore during active play to avoid accidental restarts.)
Also click on canvas/overlay starts (since iframe needs a click to receive keyboard anyway) — clicking the overlay starts the game. And during game-over, click restarts too.
**HUD.** Left: title "SNAKE" with a small snake glyph? I'll render a tiny inline SVG or just typographic. Title in tracked serif caps, cream on ink card... wait page is cream, card is ink. Let me structure: cream page background; central card = ink board with a cream mat frame? Cleaner: page cream `#EDE4D3`; the board itself is the dark element with a crisp 3px ink border and a hard offset shadow (solid, not blurry — printed feel: `box-shadow: 6px 6px 0 rgba(27,27,24,.18)`). HUD sits above the board, same width: left "SNAKE" serif bold + small subtitle "a classic, polished"; right: two stat blocks "SCORE" and "BEST" with mono digits in coral. Under board: key hints rendered as small keycap spans (bordered rounded rects) — ←↑↓→ / WASD to steer · P pause. On game over, overlay inside the board area (absolutely positioned over canvas).
Score digits: pad? Just number. Coral color `#E4572E` on cream — good contrast for large digits; labels in small tracked caps gray-brown.
**Eyes on head.** Head drawn as the stroke endpoint with larger radius — I'll draw head as a circle slightly larger than body width, then eyes: two ivory circles perpendicular offset, pupils ink, offset toward dir. Also maybe a subtle darker head cap. Direction for eyes = current render direction (interpolated heading = direction of last tick, or from prev head to head). Compute `headDir` from prev head → head cell; during death by wall, still fine.
**Self-collision detail:** newHead vs body cells excluding the tail cell if tail will move (not growing). Standard.
**Board walls:** death on wall — I'll draw the board frame with an inner border line so the playfield edge is visible; the frame itself is the wall.
**Also draw subtle checker** on cells: alternate `#1E1E1A` / `#232320`. And a faint vignette? Skip vignette (could read as gradient decoration). Keep flat, crisp.
Let me also add a subtle detail: the snake's body could taper toward tail — with a single polyline stroke, tapering needs per-segment strokes. I can draw the body as multiple short strokes with decreasing lineWidth from head (w=0.72*cell) to tail (w=0.5*cell), each segment a line between interpolated points, round caps → smooth blend. That gives a lovely taper. I'll draw back-to-front? Round caps overlapping are fine with same color; slight alpha overlap could show seams if translucent — use opaque color, no seams. Then inner highlight stroke thinner along same points with lighter coral. Then head circle at front. Nice.
Corner rounding via round lineJoin happens naturally per segment cap/join.
**Turning anticipation:** interpolation naturally curves through cell centers; good enough.
**Resize/fit:** function `fit()` sets canvas.style.width and computes backing store: `cssSize = clamp` — I'll set CSS via stylesheet: `#board { width: min(546px, 92vw, calc(100vh - 220px)); }` roughly, aspect-ratio 1. Then in JS on resize: `const css = canvas.clientWidth; canvas.width = canvas.height = Math.round(css * devicePixelRatio); ctx.setTransform(scale...)` where scale = backing/GRID_PX. I'll define logical units in cells: draw everything in cell units with transform `k = backing/COLS`. Clean: `ctx.setTransform(dpr*css/cols, 0,0, dpr*css/cols, 0,0)` then coordinates in cell units (0..21). Font sizes in cell units too — need to scale fonts accordingly; ctx font in px is in transformed units, so `ctx.font = '0.6px ...'`? Sub-pixel font sizes can be problematic in some browsers but with transform scaling Chrome handles fractional px fonts okay-ish... Safer: do all drawing in backing-store pixels with `S = backing/cols` scale factor applied manually to lengths, and fonts sized in real px. I'll compute `S` each frame from canvas.width/COLS and multiply coordinates. Slight verbosity but robust.
**Structure of code:**
- const COLS=21, ROWS=21.
- state: `snake`, `prevSnake`, `dir`, `dirQueue`, `food`, `score`, `best`, `mode` ('demo'|'play'|'over'|'paused'|'ready'), tick timing, particles, floaters, shake, eatFlash.
- `tick()`: if demo, AI decides dir. Move, check collisions, eat handling, spawn food.
- `render(now)`: clear, checker, food (pulse using now), snake (interpolated, head, eyes, tongue), particles, floaters, ring, shake transform, death flash.
- HUD update via DOM (score span, best span), pulse animation class on change.
- Overlay DOM div toggled with content per mode.
Death detection in demo → auto restart demo after 900ms (mode stays 'demo', show small "restarting" — just silently reset, keep overlay only for real game). Actually on ready/over states in demo the overlay says "Click or press a key to start"; behind it demo continues. If demo dies, reset silently.
Real game over → mode 'over', overlay with score/best, Space/Enter/click restarts to play (not demo).
Initial mode: 'demo' with overlay "SNAKE — Click or press a key to start". Hmm, the requirement: games must start in self-playing demo mode behind a "Click or press a key to start" message. Yes.
Pause in demo mode? P during demo could toggle pause too but overlay covers... ignore P in demo. During play: P toggles; show PAUSED overlay; P or click resumes? Keys frozen while paused; pressing P resumes. Space/Enter while paused → also resume? I'll make Space/Enter during pause resume (harmless). Actually spec says space restarts — ambiguous; during pause, resuming is friendlier. Fine.
**Keyboard:** listen on window keydown; preventDefault for arrows/space to stop scrolling. Keys: ArrowUp/W etc. Also 'p'/'P'. Space ' ' & Enter.
First key press: any steering key also starts the game and applies that direction if valid. Nice touch.
**Eating pop ring + HUD pulse:** add class 'pulse' to score value, remove after animation (animationend or timeout).
**Particles:** array of {x,y,vx,vy,life,maxLife,color,r,shape}. Spawn at food cell center in cell units. Update with dt in render loop (not tick), gravity ~ +9 cells/s²? small. Draw circles & tiny rects rotated. Keep it simple: circles and short lines.
**"+10" floater:** {x,y,vy,life} drawn with ctx text in coral... on dark board use ivory/coral: coral `#FF8A5C` brighter for visibility. Rise 1 cell over 0.8s, fade.
**Tongue:** every ~1.6s, for 200ms draw forked line ahead of head in the moving direction, coral-bright `#D63A2F`? head is coral; tongue could be a deeper red `#B33A2B`... against dark board, tongue in ivory? Real snakes: red tongue. Use `#FF5A3C` small. Fine.
**Death flash:** on death, snake color lerps to pale for 300ms then overlay fades in. Overlay uses CSS transition opacity.
**Board frame:** I'll draw a 1-cell... no, walls are the board edge; visually the ink area extends to canvas edge and a thin inner stroke line (1.5px, rgba ivory .15) marks the wall. Plus outer border via CSS on canvas (3px solid ink on cream page, with offset shadow). Canvas background = checker drawn each frame (or cached — fine to redraw).
Let me also add the snake spawn: length 4, centered, moving right... choose dir right; cells at (10,10),(9,10),(8,10),(7,10). Food random not on snake.
Speed ramp: `tickMs = max(72, 125 - score*0.9)`? score in tens: 125 - foods*2.2.
**Best score session:** variable `best`, updated on eat; shown in HUD; not persisted (no storage allowed anyway).
**HUD DOM:**
```
<header class="hud">
<div class="brand"><h1>SNAKE</h1><p>smooth classic · 21×21</p></div>
<div class="stats">
<div class="stat"><span class="label">Score</span><span id="score" class="value">0</span></div>
<div class="stat"><span class="label">Best</span><span id="best" class="value">0</span></div>
</div>
</header>
```
**Hints:** keycaps with arrows: I'll use characters ← ↑ ↓ → (unicode arrows are fine, not emoji). `<kbd>` styled.
**Overlay content variants** built by JS:
- ready/demo: title, "The snake is playing itself —", big "Click or press any key to start", sub-hint keys.
- paused: "PAUSED — press P to resume".
- over: "GAME OVER" big coral, "Score 120 · Best 240" or two stats, "Space / Enter to play again". Also small "New best!" tag when applicable — nice detail: track `newBest` flag when score > previous best at death... since best updates live during play, capture bestAtStart. I'll track `bestBeforeRun`; if final score > bestBeforeRun → show "NEW BEST" chip. Good juice.
Overlay styling: absolutely positioned over canvas wrap, ink translucent `rgba(20,20,18,.82)` + backdrop blur slight? backdrop-filter maybe heavy; use rgba .86 without blur or with blur(2px) — blur is fine. Centered column, serif big title.
Wait — overlay covering the demo means the demo is dimmed behind veil; that's intended ("behind a message"). Make veil somewhat transparent (.78) so play is visible behind.
**Prevent scroll on arrows/space:** e.preventDefault() when key handled.
**Edge: iframe focus** — click starts; keyboard after click works. Overlay text clearly instructs.
Let me now write the tick/interp carefully.
```
function reset(real) {
snake = [{x:10,y:10},{x:9,y:10},{x:8,y:10},{x:7,y:10}];
prevSnake = snake.map(s=>({...s}));
dir = {x:1,y:0}; dirQueue=[];
score=0; updateHUD();
spawnFood();
tickMs = 128; acc=0; lastTime=now;
particles=[]; floaters=[]; dying=false; deathAt=0;
newBestFlag = false; bestBeforeRun = best;
}
```
tick():
```
if (mode==='demo') aiSteer();
if (dirQueue.length) { const d = dirQueue.shift(); if (!(d.x===-dir.x&&d.y===-dir.y)) dir=d; }
prevSnake = snake.map(clone);
const head = snake[0];
const nx = head.x+dir.x, ny=head.y+dir.y;
// wall
if (nx<0||ny<0||nx>=COLS||ny>=ROWS) { die(); return; }
// self: body cells except tail (tail moves unless growing)
const willEat = food.x===nx&&food.y===ny;
const bodyLimit = willEat ? snake.length : snake.length-1;
for (let i=0;i<bodyLimit;i++) if (snake[i].x===nx&&snake[i].y===ny) { die(); return; }
snake.unshift({x:nx,y:ny});
if (willEat) { score+=10; ... particles, floater, ring, speed up, spawnFood(); pulse HUD; if(score>best){best=score;...} } else snake.pop();
```
Hmm wait: prevSnake captured before unshift — prevSnake = old snake. Interpolation: for new snake index i, from = prevSnake[min(i, prevLen-1)]. For non-growth: newLen = prevLen; new[i] = prev[i-1] for i≥1, new[0]=newHead. Interp from prev[i]→new[i]: prev[i] vs new[i]=prev[i-1] → each segment slides one cell toward head. Tail new[prevLen-1]=prev[prevLen-2], from prev[prevLen-1] → tail slides forward. Correct crawl. For growth: newLen=prevLen+1; last index newLen-1=prevLen; from prev[prevLen-1] = new[last] position → stationary. Correct.
die(): mode stays ('demo' or set 'over' if play). Set dying=true, deathAt=nowMs, shakeAmp=1. If play: after ~700ms show over overlay (or immediately show with fade delay via CSS transition-delay... simpler: setTimeout show overlay 600ms; but guard with mode check). Actually set mode='over' immediately but overlay element gets class 'show' after 650ms via timeout; input handler for restart should be active only after overlay shows? If user presses space during death anim, restart anyway — acceptable, but let me only accept restart keys after 400ms. Simpler: accept immediately; harmless.
For demo death: setTimeout(reset+keep demo, 900).
Interpolation during dying: t frozen at value when died? The move didn't happen, prevSnake==... on die, we returned before mutating snake, but prevSnake was already updated to clone of current snake (same) → t interp from same to same = static. Good — snake frozen at pre-collision pose. Head visually adjacent to wall/self. Add shake + flash. Good.
aiSteer():
```
const h=snake[0];
const dirs=[{x:1,y:0},{x:-1,y:0},{x:0,y:1},{x:0,y:0? no}...];
candidates = 4 dirs excluding reverse of dir;
score each: target = h+d; safe = inBounds && !bodyHit(target, excludeTail if not eating... for AI just exclude last cell);
if safe: freedom = count of free neighbors of target (excluding snake body minus tail); prefer min manhattan to food; tie-break higher freedom; pick best.
fallback: any safe regardless of distance.
```
Body hit check helper `hitsBody(x,y)`: loop snake except last cell (tail will move) — but if AI's target is food, tail doesn't move; minor, ignore. This AI is decent; occasional death is fine (auto-restart).
Also when AI eats and grows long, greedy gets stuck — fine for a demo, restarts.
**Render order per frame:**
1. compute dt, acc; while acc>=tickMs → tick(), acc-=tickMs (cap loop 3).
2. t = clamp(acc/tickMs, 0, 1). During dying/over/paused: t=1 static? During pause freeze acc (don't accumulate). I'll only accumulate when mode==='play'||'demo'.
3. Clear + checker + food + snake + particles + floaters + wall inner stroke.
4. Shake: apply translate before drawing all (random jitter scaled by remaining shake).
5. Death flash: after death, for 250ms draw snake in pale color — I'll blend body color toward ivory using deathT.
Particles/floaters update with dt each frame regardless of mode (so burst finishes even on death).
**Checker draw:** two fills — cheaper: fill whole with A, then draw B squares on alternating cells. 21×21 → ~220 rects per frame at 60fps fine.
**Snake drawing detail:**
```
pts = interpolated points in cell units (center = x+0.5).
widths: head w0=0.78, tail w1=0.46, lerp by i/(n-1).
Draw from tail to head so head overlaps on top.
For each i from n-2 down to 0? Draw segment between pts[i+1] and pts[i] with width w(i).
Actually per-segment strokes: for i in 0..n-2: line pts[i]→pts[i+1], lineWidth = lerp(w0,w1, i/(n-1)) — but width should be based on position along body; segment i between body index i and i+1, use average.
```
Round caps make it continuous. Then spine highlight: same polyline but single path with width 0.16, color lighter coral with alpha .5, drawn along pts (single path with lineJoin round) — but tapering highlight unnecessary; uniform thin spine ok. Draw spine only from index 1..n-2 to avoid poking past head? Head circle covers front. Fine: draw spine path over all points, then head circle covers the front end, tail cap round ends it. OK.
Head: circle radius 0.44 at pts[0], color slightly darker coral `#C94B2A`? Then snout highlight? Eyes: perpendicular offset ±0.16, forward offset 0.12; eye r=0.115 ivory `#F3EBDA`; pupil r=0.06 ink `#1B1B18`, offset forward 0.045. headDir from prevHead→head interpolated... just use dir (current tick dir) — during turning it snaps; acceptable; better: compute heading vector = normalize(pts[0]-pts[1]) — smooth! Use that, fallback dir. But if length 1? never (start 4, grows). If pts[0]==pts[1] ( stationary? never during play). Good.
Tongue: when (now % 1800) < 220 and mode active: from head forward: line from headPt + fwd*0.35 to +fwd*0.75, then two small prongs. Color `#E4572E` bright `#FF6B45`. Draw beneath head circle? Draw before head circle so it emerges from mouth: draw tongue first then head circle would cover base — draw tongue after body but before head? Head circle r .44, tongue starts at +0.35 within circle... draw tongue after head circle, starting at +0.3. Fine.
**Food pulse:** r = 0.28 + 0.03*sin(now/300). Ring: r = 0.34 + 0.1*((now/900)%1), alpha = .35*(1-phase). Ivory `#F5EDDC` core, inner `#FFFDF6`? plus tiny leaf? Keep orb + ring.
Actually, to distinguish: food as ivory pearl with soft ring — good.
**Colors final:**
- page `#EDE3D0` cream; text ink `#1F1D1A`.
- board cells `#21211D` / `#26261F`? subtle. Wall inner stroke rgba(240,234,218,0.14).
- snake body `#E4572E`, spine `#F2916B` alpha .55, head `#C8431F`... head darker for contrast with eyes. Let me: head `#D34A24`, body `#E4572E`.
- dead flash: lerp body toward `#F3EBDA`.
- food `#F3EBDA`, ring same.
- HUD labels `#8A8172` on cream; values ink; score value maybe coral `#D8431F`. Best value ink.
- overlay title cream, big serif.
- Accent coral used sparingly: score number, game-over title, snake, tongue, "+10".
Typography: `font-family: Georgia, 'Iowan Old Style', 'Times New Roman', serif` for title; `ui-monospace, 'Cascadia Mono', 'SF Mono', Menlo, Consolas, monospace` for digits/kbd. Title with letter-spacing .35em.
**Card layout:** center column with `display:grid; place-items:center; min-height:100vh`. Board width: `min(546px, 92vw, 62vh)`? Height budget: 800 viewport: HUD ~64 + gaps + hints ~30 + board ≤ 546 → fits. Use `width:min(546px, 92vw)` and also cap by `calc(100svh - 170px)`. Use CSS min() with those three. `aspect-ratio:1/1`.
**Responsive:** at 375px: board ~345px, HUD wraps (stats right, brand left, ok), hints may wrap. Ensure no horizontal scrollbar: body margin 0, overflow-x hidden safe.
**dPR backing:** in fit(): `const css = canvas.getBoundingClientRect().width; const px = Math.max(1, Math.round(css*dpr)); canvas.width=canvas.height=px;` called on resize + init. Since CSS controls displayed size. S = canvas.width / COLS.
Drawing helpers: `cx(x)=x*S` etc. I'll just multiply inline.
**Fonts in canvas:** floater "+10": `bold ${0.55*S}px ui-monospace,...`.
**Shake:** `shake = max(0, 1 - (now-deathAt)/450)`; offset = shake^2 * 0.35*S * random sign. Applied via ctx.translate at frame start (after clear? translate then draw everything including checker — jitter whole board content within canvas edges; edges show page... canvas cleared color should be board base so gaps invisible. Fill board base color full canvas first without translate, then translate and draw checker etc. Or apply shake as CSS transform on canvas element — simpler and shakes the whole framed board incl. border: nice. I'll do CSS: `canvas.style.transform = translate(...)` when shaking, reset otherwise. Cheaper. But border frame shake looks good.
**HTML skeleton:**
```html
<div class="wrap">
<header class="hud">...</header>
<div class="stage">
<canvas id="cv"></canvas>
<div id="overlay" class="overlay">...dynamic...</div>
</div>
<footer class="hints">...kbd...</footer>
</div>
```
Overlay innerHTML set by JS `showOverlay(html)` / hide.
Demo auto-restart timer: guard multiple timeouts with a token.
Pause behavior: when paused, stop accumulating; render continues (food pulse keeps animating? freeze food pulse too? keep animating subtle — fine, but snake static). P toggles.
Mode transitions:
- 'demo': overlay ready shown. tick runs.
- key/click → startPlay(): reset(true); mode='play'; hideOverlay(). If the key is a steering key, push it to queue (so first press steers immediately).
- 'play': P → 'paused' show pause overlay. keys steer.
- 'paused': P (or Space/Enter? or click) → resume 'play', hide overlay.
- death in play → mode='over'; after 650ms show over overlay (timeout). Space/Enter/click → startPlay().
- death in demo → silent reset after 900ms, stay 'demo', overlay unchanged.
Also while 'over' before overlay shows, block restart for 300ms to avoid accidental skip — minor; allow after 500ms via `overShownAt`. I'll allow restart only when overlay visible (flag).
Click handling: overlay click → same as key action for that state (start/resume/restart). Also clicking canvas during play does nothing.
**AI steer details:**
```
function aiSteer(){
const h=snake[0];
const dirs=[{x:0,y:-1},{x:0,y:1},{x:-1,y:0},{x:1,y:0}];
const opts=[];
for(const d of dirs){
if(d.x===-dir.x && d.y===-dir.y) continue;
const nx=h.x+d.x, ny=h.y+d.y;
if(nx<0||ny<0||nx>=COLS||ny>=ROWS) continue;
if(hits(nx,ny)) continue; // body except tail
let freedom=0;
for(const e of dirs){ const ax=nx+e.x, ay=ny+e.y; if(ax<0||ay<0||ax>=COLS||ay>=ROWS) continue; if(!hits(ax,ay)) freedom++; }
const dist=Math.abs(nx-food.x)+Math.abs(ny-food.y);
opts.push({d,dist,freedom});
}
if(!opts.length) return; // doomed
opts.sort((a,b)=> (a.dist-b.dist) || (b.freedom-a.freedom));
dirQueue=[opts[0].d];
}
```
hits(x,y): for i<snake.length-1 compare (exclude tail). Good. dirQueue replaced each AI tick — but during demo the queue is AI's; on starting play, clear queue.
One more: prefer forward-ish to avoid oscillation — Manhattan handles it mostly. Might jitter between two dirs with equal dist — tie-break stable enough. Add tiny randomness? skip.
**HUD pulse CSS:**
```
.value.pop { animation: pop .3s ease; }
@keyframes pop { 40% { transform: scale(1.35); } }
```
transform on inline span → make .value display:inline-block.
**Overlay markup (built strings):**
Ready:
```
<div class="ov-inner">
<p class="ov-kicker">DEMO — the snake plays itself</p>
<h2 class="ov-title">SNAKE</h2>
<p class="ov-action">Click or press a key to start</p>
<p class="ov-sub"><kbd>↑</kbd><kbd>←</kbd><kbd>↓</kbd><kbd>→</kbd> or <kbd>W</kbd><kbd>A</kbd><kbd>S</kbd><kbd>D</kbd> steer · <kbd>P</kbd> pause</p>
</div>
```
Over:
```
kicker: NEW BEST (conditional chip) or "GAME OVER"... structure:
<h2 class="ov-title">GAME OVER</h2>
<div class="ov-score"><span>score</span><b>120</b></div> <div best>
<p class="ov-action">Space or Enter to play again</p>
```
Maybe combine: two stat blocks side by side.
Paused: title "PAUSED", action "Press P to resume".
Also demo behind over? Demo never shows over overlay.
**In-canvas "wall" visual:** maybe draw thin inner stroke rect inset 2px. And corner ticks? Keep simple stroke.
Extra polish: subtle grid? Checker already gives texture. Good.
Let me now also handle "quick successive presses honored": queue approach does that (two presses within one tick → applied over two ticks). Also ensure same-direction duplicates don't queue spam: skip if same as last effective dir. `lastQueued = dirQueue.length? dirQueue[dirQueue.length-1] : dir`. Skip if d equals lastQueued or is reverse of lastQueued (reverse will be invalid later anyway; skipping prevents dead entries). Cap queue at 3.
Edge: pressing a key that is reverse of current dir while queue holds a perpendicular move... e.g., dir=right, queue=[up], press left: left is reverse of dir(right)? left vs right — reverse → skipped even though after 'up' it'd be valid. Standard tradeoff; acceptable (most implementations do check against last queued: lastQueued=up; left is not reverse of up and not equal → queued. Then tick1: apply up; tick2: apply left — both honored! That's the better behavior: validate against lastQueued, not dir.) But validation at tick time also checks against actual dir — since queue entries were validated against predecessor chain, each is non-reverse relative to previous effective direction. But careful: if queue=[up] and then snake... it's consistent. However at tick time I apply dirQueue.shift() with reverse check vs current dir as safety; if it fails, drop and try next in queue? Chain validation makes failure impossible except... fine, keep safety check that drops invalid head and continues to next.
Let me write the render function fully.
```
function frame(nowMs){
requestAnimationFrame(frame);
const t = now;
let dt = (now - lastFrame)/1000; lastFrame=now; dt=min(dt,0.05);
if(mode==='play'||mode==='demo'){
acc += (now-lastTickTime)...
```
Simpler: track acc in ms: each frame `acc += dt*1000`; while(acc>=tickMs){acc-=tickMs; tick();}. t = acc/tickMs.
When paused or over: don't accumulate.
Update particles with dt.
Render:
```
const S = cv.width/COLS;
ctx.setTransform(1,0,0,1,0,0);
ctx.fillStyle=C_BG; ctx.fillRect(0,0,cv.width,cv.height);
// checker
ctx.fillStyle=C_CELL_A; fillRect all? bg is C_CELL_A; then B on (i+j)%2.
for cells...
// shake via CSS transform on canvas (outside)
// food
// snake
// particles, floaters
// wall stroke
```
Shake CSS: in frame, if shakeT>0: compute jitter, set style.transform; else clear transform ('').
Death flash: `deathT = dying ? clamp((now-deathAt)/350,0,1) : 0`; body color = lerpColor(coral, ivory, deathT*0.85). Write lerpColor with hex parse.
Alpha fade of snake after death? Keep visible under overlay veil. OK.
**spawnFood:** free cells list; if none (win!) → treat as victory: show over overlay "YOU WIN"? 441 cells, unlikely; still handle: if no free cell, die-less win overlay. I'll just spawn nothing and show game over with "Perfect!" — minimal code: if free.length===0 → win=true; gameOver(). Eh, I'll include a tiny branch.
**Numbers formatting:** score as-is.
Now CSS details:
```css
:root{ --paper:#EDE3D0; --ink:#1F1D19; --board:#21211C; --cellA:#21211C; --cellB:#262620; --coral:#E4572E; --coral-deep:#C8431F; --ivory:#F3EBDA; --muted:#8B8170; }
*{box-sizing:border-box}
html,body{height:100%}
body{margin:0;background:var(--paper); color:var(--ink); font-family: Georgia, 'Times New Roman', serif; display:grid; place-items:center; min-height:100vh; overflow:hidden? }
```
overflow hidden on body to prevent scrollbars from kbd focus? Better allow but ensure fits; use `min-height:100svh; padding: 16px;` and overflow hidden horizontally only. I'll set body{overflow:hidden} — content designed to fit; safe. Hmm at 375×667 landscape? Requirements only 1280×800→375 width. With width min() and height cap, fits. I'll keep overflow:hidden to guarantee no scrollbars.
.wrap{display:flex; flex-direction:column; align-items:center; gap:14px; width:100%; max-width:560px;}
.hud{width:100%; display:flex; justify-content:space-between; align-items:flex-end;}
.brand h1{margin:0; font-size:30px; letter-spacing:.42em; font-weight:700; } maybe include trailing spacing issue — fine.
.brand p{margin:2px 0 0; font-size:11px; letter-spacing:.18em; text-transform:uppercase; color:var(--muted); font-family:mono}
.stats{display:flex; gap:18px;}
.stat{text-align:right}
.label{display:block; font-size:10px; letter-spacing:.22em; uppercase; color:muted; mono}
.value{font: 700 26px mono; color: ink; } score value coral.
.stage{position:relative; width:min(546px, 92vw, calc(100svh - 200px)); aspect-ratio:1; }
canvas{display:block; width:100%; height:100%; background:var(--board); border:3px solid var(--ink); box-shadow: 8px 8px 0 rgba(31,29,25,.15); border-radius: 6px? } slight radius 10px, with overflow overlay matching radius.
overlay{position:absolute; inset:3px; border-radius:8px; background:rgba(24,23,20,.8); display:flex; center; opacity transitions; pointer-events... always clickable}
When hidden: opacity 0, visibility hidden.
kbd{font-family:mono; border:1px solid rgba(243,235,218,.4); border-bottom-width:3px; border-radius:6px; padding:2px 7px; font-size:12px; color:var(--ivory); background:rgba(243,235,218,.08)}
footer hints (outside board, on paper): kbds styled for paper: border ink-ish, color ink.
Two kbd styles: .overlay kbd vs footer kbd. Use different selectors.
Footer: `<footer class="hints"><kbd>←</kbd>... <span>steer</span> <kbd>P</kbd><span>pause</span> ...` with mono small muted.
Game-over overlay score: big mono coral digits.
"NEW BEST" chip: inline-block, coral bg, paper text, small caps.
**Now the JS in full.** Let me draft carefully to avoid bugs.
```js
const COLS=21, ROWS=21;
const cv=document.getElementById('cv'), ctx=cv.getContext('2d');
const overlay=document.getElementById('overlay');
const scoreEl=..., bestEl=...;
let mode='demo';
let snake, prevSnake, dir, dirQueue, food, score, best=0, bestBeforeRun=0, newBest=false;
let tickMs=128, acc=0;
let particles=[], floaters=[], rings=[];
let dying=false, deathAt=0, deadFlash=0;
let lastFrame=performance.now();
let overShown=false; // whether restart accepted
let demoResetTimer=null;
```
fit():
```
function fit(){
const r=cv.getBoundingClientRect();
const dpr=Math.min(2.5, window.devicePixelRatio||1);
const px=Math.max(300, Math.round(r.width*dpr));
if(cv.width!==px){ cv.width=cv.height=px; }
}
window.addEventListener('resize', fit);
```
Call fit each frame? Cheap check — call in frame to catch CSS min() changes; fine (getBoundingClientRect each frame ok). I'll call at frame start.
Reset:
```
function resetGame(){
const cx0=Math.floor(COLS/2), cy0=Math.floor(ROWS/2);
snake=[]; for(let i=0;i<4;i++) snake.push({x:cx0-i,y:cy0});
prevSnake=snake.map(p=>({x:p.x,y:p.y}));
dir={x:1,y:0}; dirQueue=[];
score=0; bestBeforeRun=best; newBest=false;
tickMs=130; acc=0;
particles=[]; floaters=[]; rings=[];
dying=false; deadFlash=0;
spawnFood(); updateHUD(false);
}
```
spawnFood:
```
function spawnFood(){
const free=[];
const occ=new Set(snake.map(p=>p.x+','+p.y));
for(let y=0;y<ROWS;y++)for(let x=0;x<COLS;x++){ if(!occ.has(x+','+y)) free.push({x,y}); }
if(!free.length){ food=null; win(); return; }
food=free[(Math.random()*free.length)|0];
}
```
hits(x,y, includeTail):
```
function hitsBody(x,y){
for(let i=0;i<snake.length-1;i++) if(snake[i].x===x&&snake[i].y===y) return true;
return false;
}
```
Tail excluded always (tail moves unless eating; if target is food, tail stays — edge case: target == food cell can't be on body anyway since food never spawns on snake... target is food cell which is not on snake. fine.)
tick():
```
function tick(){
if(mode==='demo') aiSteer();
// apply queued
while(dirQueue.length){
const d=dirQueue.shift();
if(d.x===-dir.x && d.y===-dir.y) continue;
if(d.x===dir.x && d.y===dir.y) continue;
dir=d; break;
}
prevSnake=snake.map(p=>({x:p.x,y:p.y}));
const h=snake[0], nx=h.x+dir.x, ny=h.y+dir.y;
if(nx<0||ny<0||nx>=COLS||ny>=ROWS){ die(); return; }
if(hitsBody(nx,ny)){ die(); return; }
snake.unshift({x:nx,y:ny});
if(food && nx===food.x && ny===food.y){
score+=10;
if(score>best){ best=score; }
updateHUD(true);
burst(nx,ny);
tickMs=Math.max(74, tickMs-2.4);
spawnFood();
} else snake.pop();
}
```
Wait: reversal check — applying queued dir when d is same as dir: skip and try next queued (allows second press to apply same tick? e.g., queue=[up, left]: tick applies up (dir=up), next tick applies left. Good — one per tick.)
die():
```
function die(){
dying=true; deathAt=performance.now();
if(mode==='play'){
mode='over'; overShown=false;
setTimeout(()=>{ if(mode==='over'){ showOver(); overShown=true; } }, 620);
} else {
// demo: silent restart
clearTimeout(demoResetTimer);
demoResetTimer=setTimeout(()=>{ if(mode==='demo'){ resetGame(); } }, 900);
}
}
```
During 'over' before reset: keep snake frozen, particles update. mode==='over' → no ticking.
win(): mode='over'; showOver with "PERFECT" — rare; skip complexity: reuse showOver with title param. Fine.
startPlay():
```
function startPlay(steerDir){
clearTimeout(demoResetTimer);
resetGame();
mode='play';
hideOverlay();
if(steerDir) queueDir(steerDir);
}
```
queueDir(d):
```
function queueDir(d){
const last = dirQueue.length? dirQueue[dirQueue.length-1] : dir;
if((d.x===last.x&&d.y===last.y) || (d.x===-last.x&&d.y===-last.y)) return;
if(dirQueue.length<3) dirQueue.push(d);
}
```
keydown:
```
const KEYMAP={ArrowUp:U, KeyW:U, ...} using e.code.
window.addEventListener('keydown', e=>{
const code=e.code;
if(['ArrowUp','ArrowDown','ArrowLeft','ArrowRight','Space'].includes(code)) e.preventDefault();
const d = DIRS_BY_KEY[code];
if(code==='KeyP'){ if(mode==='play'){mode='paused'; showPause();} else if(mode==='paused'){mode='play'; hideOverlay(); lastFrame=performance.now();} return; }
if(mode==='demo'){ if(d) startPlay(d); else startPlay(null); return; } // any key starts
if(mode==='paused'){ if(d||code==='Space'||code==='Enter'){ mode='play'; hideOverlay(); } return; }
if(mode==='play'){ if(d) queueDir(d); return; }
if(mode==='over'){ if(overShown && (code==='Space'||code==='Enter')) startPlay(); return; }
});
```
Any key in demo starts — including P/space: fine, message says "press a key".
Hmm: when starting with a steering key: startPlay(d) resets (dir right) then queueDir(d): if d is left (reverse of right) it's rejected → snake goes right. Acceptable. If d is up/down: applied next tick. Good.
Also prevent default for WASD not needed.
click on overlay:
```
overlay.addEventListener('click', ()=>{
if(mode==='demo') startPlay();
else if(mode==='paused'){ mode='play'; hideOverlay(); }
else if(mode==='over' && overShown) startPlay();
});
```
HUD update:
```
function updateHUD(pop){
scoreEl.textContent=score;
bestEl.textContent=best;
if(pop){ scoreEl.classList.remove('pop'); void scoreEl.offsetWidth; scoreEl.classList.add('pop'); }
}
```
burst(x,y):
```
function burst(x,y){
const cx=x+0.5, cy=y+0.5;
for(let i=0;i<16;i++){
const a=Math.random()*Math.PI*2, sp=2.2+Math.random()*3.4;
particles.push({x:cx,y:cy,vx:Math.cos(a)*sp,vy:Math.sin(a)*sp-1.2,life:0,max:0.5+Math.random()*0.35,r:0.05+Math.random()*0.09,c: Math.random()<0.55? '#F3EBDA':'#E4572E', rect: Math.random()<0.4});
}
rings.push({x:cx,y:cy,t:0});
floaters.push({x:cx,y:cy-0.2,t:0});
}
```
Speed in cells/sec; gravity ~7 cells/s². Update: p.x+=vx*dt; p.vy+=7*dt; life+=dt; kill life>max. Draw alpha=1-life/max, size r*(1 - .5*life/max)? Keep r*(1-k*0.6). Rect particles: small squares rotated? plain small squares as particles are something I dislike — I'll use circles and short line-streaks (draw line from pos to pos - v*0.03) instead. So shape: 'dot' or 'streak'.
Rings: t+=dt; draw circle r=0.2+t*2.2? over 0.45s, alpha (1-t/0.45)*0.5, lineWidth 0.06*S. Remove t>0.45.
Floaters: t+=dt over 0.9s; y - t*1.1; alpha 1-t/0.9; text '+10'.
Render snake:
```
const n=snake.length;
const pts=[];
for(let i=0;i<n;i++){
const cur=snake[i];
const prev=prevSnake[Math.min(i, prevSnake.length-1)] || cur;
pts.push({x:(prev.x+(cur.x-prev.x)*tt)+0.5, y:...+0.5});
}
```
tt = mode play/demo ? acc/tickMs : (dying? frozen value...). During dying: acc frozen at whatever; interpolation from prev to cur where prev==cur? On death we set prevSnake=clone of snake before returning → prev==cur → static regardless of tt. But wait, on die() we had already set prevSnake=snake clone — yes before collision checks. So pts static. During 'over', same. During pause, acc frozen → tt constant → static.
But careful: on the very first frame after reset, prevSnake==snake → static, fine.
tt clamp 0..1.
Body draw:
```
const flash = dying? Math.min(1,(now-deathAt)/300):0;
const bodyCol = mix('#E4572E', '#F3EBDA', flash*0.85);
const spineCol = mix('#F2916B','#F3EBDA', flash*0.85) with alpha .5;
for(let i=n-2;i>=0;i--){
const w = lerp(0.74, 0.42, i/(Math.max(1,n-1)));
ctx.strokeStyle=bodyCol; ctx.lineWidth=w*S; ctx.lineCap='round';
ctx.beginPath(); ctx.moveTo(pts[i+1]...);
```
Wait direction: segment between body point i and i+1; width by position: i from head(0) to tail(n-1). Width at segment i = lerp(0.74,0.42, (i+0.5)/(n-1)). Draw all segments opaque same color — order irrelevant (opaque). Then spine: path through pts[0..n-1], lineWidth 0.13*S, strokeStyle rgba(255,214,180,0.5)? I'll use `rgba(255,222,196,0.45)`.
Then head: circle at pts[0], r=0.46*S, fill mix('#D14A22', ivory, flash*.85). Slight directional snout: maybe draw head as circle plus small forward bump — circle fine.
Eyes: heading = normalize(pts[0]-pts[1]); if zero fallback dir. perp=(-h.y,h.x).
```
for(s of [-1,1]){
ex=pts0.x + heading.x*0.14 + perp.x*0.17*s; ey=...
ivory circle r0.12; pupil ink r0.055 at +heading*0.05;
}
```
During dead flash, eyes become X? Cute: if dying, draw X eyes (two crossing lines) instead — nice detail. When deadFlash>0: draw X marks in ink. I'll do: if dying → draw x eyes.
Tongue:
```
if(!dying && (now%1700)<230){
const k=Math.sin(((now%1700)/230)*Math.PI); // 0→1→0
tip = pts0 + heading*(0.45+0.35*k);
base = pts0 + heading*0.30;
stroke line base→tip width .07*S color '#FF6B45'? plus fork: from mid to tip±perp*0.08.
}
```
Draw tongue before head fill? Tongue extends beyond head circle (0.46) so draw after head is fine, base hidden under? base at 0.30 < r 0.46 → overlapped by head if drawn before. Draw after head circle, starting base 0.30 — visible line inside head... it'd draw over the head fill. Draw tongue first (under head), extending to 0.8 — outside part visible. Fork at end. Good: draw tongue before head circle.
Wall stroke: after snake: `ctx.strokeStyle='rgba(243,235,218,0.13)'; ctx.lineWidth=2; strokeRect(1.5? ...)` in device px: strokeRect(2,2,cv.width-4,cv.height-4) with setTransform reset. Also gives crisp frame.
Food render (before snake so snake overlaps? food under snake head when eaten same tick — food removed on eat, fine):
```
if(food){
const fx=(food.x+0.5)*S, fy=(food.y+0.5)*S;
const pulse=Math.sin(now/280);
ctx.fillStyle='#F3EBDA'; ctx.beginPath(); ctx.arc(fx,fy,(0.27+0.025*pulse)*S,0,7); ctx.fill();
ctx.fillStyle='rgba(255,255,255,0.9)'? small inner highlight circle offset -0.08: rgba(255,252,244,.8) r 0.09*S at (fx-0.07S, fy-0.07S).
ring: ph=((now%1100)/1100); rr=(0.34+ph*0.42)*S; alpha=(1-ph)*0.4; stroke ivory.
}
```
Checker:
```
ctx.fillStyle='#21211C'; fillRect(0,0,cv.width,cv.height);
ctx.fillStyle='#26261F';
for(y..)for(x..) if((x+y)%2) fillRect(x*S,y*S,S+0.5,S+0.5) // +0.5 avoid seams? use Math.ceil sizes
```
Hairline seams due to fractional S: fillRect(x*S, y*S, S, S) with S fractional can leave seams; add 1px overlap: width S+1. But at right edge overflow — clipped, fine.
Font for floaters: ctx.font=`700 ${0.55*S}px ui-monospace, Menlo, Consolas, monospace`; textAlign center. Color '#FFD9A8'? Coral bright '#FF8A5C'. alpha fade, slight rise. Also slight scale? Keep simple.
Demo AI steering bug check: aiSteer sets dirQueue=[best]; then tick applies it. But queue validation: if best equals current dir → "continue" skips it and tries next queue item (none) → dir unchanged → fine, moving same dir. Good. If best is reverse? We filtered reverse in candidates. Good.
One more subtlety: aiSteer runs at tick start before applying queue — order: aiSteer replaces queue; then apply. Good.
Overlay show functions:
```
function showReady(){ overlay.innerHTML=`...`; overlay.classList.add('show'); }
function showPause(){...}
function showOver(){...}
function hideOverlay(){ overlay.classList.remove('show'); }
```
CSS: .overlay{opacity:0; visibility:hidden; transition: opacity .35s, visibility .35s;} .overlay.show{opacity:1; visibility:visible;}
Over content:
```
<p class="ov-kicker">${newBest?'NEW BEST':'BETTER LUCK NEXT TIME'}</p>?
```
kicker: if newBest (score>=bestBeforeRun && score>0 && bestBeforeRun... newBest = score > bestBeforeRun). Compute at die(): newBest = score > bestBeforeRun && score>0? If bestBeforeRun=0 any score is new best — show chip only if score>0. OK.
Over:
```
<div class="ov-inner">
<h2 class="ov-title">GAME OVER</h2>
<div class="ov-stats">
<div><span>Score</span><b>${score}</b></div>
<div><span>Best</span><b>${best}</b></div>
</div>
${newBest?'<span class="chip">New best!</span>':''}
<p class="ov-action">Space or Enter to play again</p>
</div>
```
Order: chip above stats maybe. Fine.
Ready overlay includes note "the snake is playing itself — jump in anytime".
Title font sizes clamp with vw for mobile: .ov-title{font-size:clamp(30px,7vw,44px)}.
**Canvas radius vs checker:** canvas has border-radius 10px; content square corners → checker corners get clipped visually by border-radius (background clip works since canvas element rounds its own paint? No — canvas content isn't clipped by border-radius... actually border-radius does clip the canvas element's painted content? For replaced elements, border-radius clips overflow including content? CSS: border-radius clips the element's background and content (overflow) — for canvas, the element box paints content within padding box; border-radius applies clip to background+border but content of replaced elements... In practice, border-radius on canvas does clip the displayed bitmap? I believe border-radius does clip canvas content visually (it's treated like overflow on the element itself). Yes — border-radius clips replaced element content. OK so rounded corners fine, checker clipped nicely.
But my wall stroke rect at corners gets clipped too — fine.
**Height cap:** `.stage{ width:min(546px, 92vw, calc(100svh - 210px)); }` at 800 height: 800-210=590 → 546 wins. At small heights fine.
**Initial paint within 3s:** immediately demo running; overlay visible; screenshot catches demo in motion.
**Best during demo counts toward best?** bestBeforeRun captured at startPlay. In demo, score>best updates best — acceptable ("session best"). OK.
**Prevent double space scrolling:** preventDefault on Space always. Also prevent arrows.
**One more juice:** subtle sound? WebAudio needs user gesture; skip — keep silent (sandbox audio could be blocked anyway). Actually a tiny click/eat blip via WebAudio after first interaction could be nice... but risk of console issues in sandbox; skip to be safe? Audio is generally allowed. I'll add a minimal WebAudio blip on eat and death, created lazily on first user gesture (startPlay), wrapped in try/catch. Small code, adds polish. Let me include: `let actx=null; function blip(freq,dur,type){ try{ actx=actx||new (window.AudioContext||window.webkitAudioContext)(); const o=actx.createOscillator(), g=actx.createGain(); o.type=type; o.frequency.value=freq; g.gain.setValueAtTime(0.08, actx.currentTime); g.gain.exponentialRampToValueAtTime(0.0001, actx.currentTime+dur); o.connect(g).connect(actx.destination); o.start(); o.stop(actx.currentTime+dur);}catch(e){} }`. Eat: blip(660→ two-tone quick). Death: low 130 saw. Keep gain small. Resume context on gesture: actx.resume() in startPlay try/catch. OK, include modestly.
Hmm — demo eat sounds before user gesture: no context yet, blip silently fails/creates context in suspended state — creating AudioContext without gesture logs a warning in Chrome ("The AudioContext was not allowed to start") — that's a console warning, not error. To keep zero-noise console, only create context after first real gesture: guard `if(!userGestured) return;`. Set userGestured=true in startPlay/click. Demo then silent. Good.
**Write the final code now.** Also double-check interpolation when growing: prevSnake length = old n; new n = old n+1; index min(i, prevLen-1) → last uses prev[last] which equals its cur → static. ✓.
Turning corners with polyline: segment from prev-center to cur-center per body index — the polyline through points creates natural rounded corners via round caps/joins. Note per-segment lines drawn separately with round caps: corners will look continuous. ✓
Let me make sure the head interpolation looks right when turning: pts[0] moves from prevHead to newHead linearly — fine.
**Ticker catch-up cap:** while loop max 4 iterations to avoid spiral.
**When tab inactive / long dt:** dt clamp 50ms.
Now HUD pop animation: value span inline-block; transform origin center right? fine.
Footer hint layout: `display:flex; gap:10px; align-items:center; font-family:mono; font-size:12px; color:var(--muted);` groups: `<span class="keys"><kbd>↑</kbd><kbd>←</kbd><kbd>↓</kbd><kbd>→</kbd></span> steer` etc. Arrows unicode ↑←↓→.
Also mobile note? No touch controls required; maybe swipe support small addition — skip, keep spec. Actually a quick touch swipe would be nice for 375px... not required; but "adapt gracefully" is layout. I'll add simple swipe on canvas for bonus: touchstart/touchend delta → queueDir; tap (small delta) → start/resume/restart. Cheap ~15 lines, real value on mobile. Include.
Also prevent default on touchmove to avoid scroll — canvas touch-action:none CSS handles it.
Write everything now. Estimate ~450 lines. Let me be meticulous with syntax.
Palette check for contrast: coral #E4572E on dark #21211C — good. Ivory eyes pop. HUD coral digits on cream — ok.
Checker colors: A=#211F1A? Let me: A='#221F1B', B='#272420'. Wall stroke rgba ivory .12.
Page bg #EDE3D1. Border ink #1F1D19.
Value colors: score coral #D8431F, best ink #1F1D19.
Let me write out.
```html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Snake</title>
<style> ... </style>
</head>
<body>
<div class="wrap">
<header class="hud">
<div class="brand">
<h1>SNAKE</h1>
<p>21 × 21 · smooth classic</p>
</div>
<div class="stats">
<div class="stat"><span class="label">Score</span><span class="value coral" id="score">0</span></div>
<div class="stat"><span class="label">Best</span><span class="value" id="best">0</span></div>
</div>
</header>
<div class="stage">
<canvas id="cv"></canvas>
<div id="overlay" class="overlay"></div>
</div>
<footer class="hints">
<span class="group"><kbd>↑</kbd><kbd>←</kbd><kbd>↓</kbd><kbd>→</kbd><em>or</em><kbd>W</kbd><kbd>A</kbd><kbd>S</kbd><kbd>D</kbd><span>steer</span></span>
<span class="group"><kbd>P</kbd><span>pause</span></span>
</footer>
</div>
<script> ... </script>
</body>
</html>
```
JS full:
```js
(() => {
'use strict';
const COLS=21, ROWS=21;
const C={
board:'#221F1B', cellB:'#272420',
body:'#E4572E', head:'#D14A22', spine:'rgba(255,219,190,0.45)',
ivory:'#F3EBDA', ink:'#171512',
tongue:'#FF6B45',
};
const cv=document.getElementById('cv');
const ctx=cv.getContext('2d');
const overlay=document.getElementById('overlay');
const scoreEl=document.getElementById('score');
const bestEl=document.getElementById('best');
let mode='demo';
let snake=[], prevSnake=[], dir={x:1,y:0}, dirQueue=[];
let food=null, score=0, best=0, bestBeforeRun=0, newBest=false;
let tickMs=130, acc=0;
let particles=[], floaters=[], rings=[];
let dying=false, deathAt=0;
let overShown=false;
let demoTimer=null;
let userGestured=false;
let lastFrame=performance.now();
```
DIRS:
```
const UP={x:0,y:-1}, DOWN={x:0,y:1}, LEFT={x:-1,y:0}, RIGHT={x:1,y:0};
const KEY_DIR={ArrowUp:UP,KeyW:UP,ArrowDown:DOWN,KeyS:DOWN,ArrowLeft:LEFT,KeyA:LEFT,ArrowRight:RIGHT,KeyD:RIGHT};
```
Audio:
```
let actx=null;
function blip(f0,f1,dur,type,vol){
if(!userGestured) return;
try{
actx=actx||new (window.AudioContext||window.webkitAudioContext)();
if(actx.state==='suspended') actx.resume();
const t=actx.currentTime;
const o=actx.createOscillator(), g=actx.createGain();
o.type=type||'triangle';
o.frequency.setValueAtTime(f0,t);
if(f1) o.frequency.exponentialRampToValueAtTime(f1,t+dur);
g.gain.setValueAtTime(vol||0.06,t);
g.gain.exponentialRampToValueAtTime(0.0001,t+dur);
o.connect(g); g.connect(actx.destination);
o.start(t); o.stop(t+dur+0.02);
}catch(err){}
}
```
Helpers mix colors:
```
function hexToRgb(h){ return [parseInt(h.slice(1,3),16),parseInt(h.slice(3,5),16),parseInt(h.slice(5,7),16)]; }
function mix(a,b,k){ const A=hexToRgb(a),B=hexToRgb(b); return `rgb(${A.map((v,i)=>Math.round(v+(B[i]-v)*k)).join(',')})`; }
```
resetGame, spawnFood, hitsBody, aiSteer, tick, die, burst as planned.
updateHUD(pop).
Overlays:
```
const kbdRow='<kbd>↑</kbd><kbd>←</kbd><kbd>↓</kbd><kbd>→</kbd> <em>or</em> <kbd>W</kbd><kbd>A</kbd><kbd>S</kbd><kbd>D</kbd>';
function showReady(){
overlay.innerHTML=`
<div class="ov-inner">
<p class="ov-kicker">demo · the snake plays itself</p>
<h2 class="ov-title">SNAKE</h2>
<p class="ov-action">Click or press a key to start</p>
<p class="ov-sub">${kbdRow} steer · <kbd>P</kbd> pause</p>
</div>`;
overlay.classList.add('show');
}
function showPause(){
overlay.innerHTML=`<div class="ov-inner"><h2 class="ov-title">PAUSED</h2><p class="ov-action">Press <kbd>P</kbd> to resume</p></div>`;
overlay.classList.add('show');
}
function showOver(won){
overlay.innerHTML=`
<div class="ov-inner">
<h2 class="ov-title">${won?'PERFECT!':'GAME OVER'}</h2>
${newBest?'<span class="chip">New best</span>':''}
<div class="ov-stats">
<div><span>Score</span><b>${score}</b></div>
<div><span>Best</span><b>${best}</b></div>
</div>
<p class="ov-action">Space or Enter to play again</p>
</div>`;
overlay.classList.add('show');
}
function hideOverlay(){ overlay.classList.remove('show'); }
```
Start:
```
function startPlay(d){
clearTimeout(demoTimer);
userGestured=true;
resetGame();
mode='play';
hideOverlay();
if(d) queueDir(d);
blip(520,760,0.12,'triangle',0.05);
}
```
Input:
```
window.addEventListener('keydown', (e)=>{
const d=KEY_DIR[e.code];
if(d || e.code==='Space' || e.code==='Enter') e.preventDefault();
if(e.code==='KeyP'){
if(mode==='play'){ mode='paused'; showPause(); }
else if(mode==='paused'){ mode='play'; hideOverlay(); }
return;
}
if(mode==='demo'){ startPlay(d||null); return; }
if(mode==='play'){ if(d) queueDir(d); return; }
if(mode==='paused'){ mode='play'; hideOverlay(); return; }
if(mode==='over' && overShown && (e.code==='Space'||e.code==='Enter')){ startPlay(); }
});
overlay.addEventListener('click', ()=>{
if(mode==='demo') startPlay();
else if(mode==='paused'){ mode='play'; hideOverlay(); }
else if(mode==='over' && overShown) startPlay();
});
```
Touch:
```
let touchStart=null;
cv.addEventListener('touchstart', e=>{ const t=e.changedTouches[0]; touchStart={x:t.clientX,y:t.clientY}; }, {passive:true});
cv.addEventListener('touchend', e=>{
if(!touchStart) return;
const t=e.changedTouches[0], dx=t.clientX-touchStart.x, dy=t.clientY-touchStart.y;
touchStart=null;
const adx=Math.abs(dx), ady=Math.abs(dy);
if(Math.max(adx,ady)<24){ // tap
if(mode==='demo') startPlay();
else if(mode==='paused'){ mode='play'; hideOverlay(); }
else if(mode==='over'&&overShown) startPlay();
return;
}
if(mode!=='play') return;
queueDir(adx>ady ? (dx>0?RIGHT:LEFT) : (dy>0?DOWN:UP));
}, {passive:true});
```
Overlay covers canvas when shown so taps on overlay handled by overlay click — touchend on canvas only fires when overlay hidden mostly; fine.
Overlay click on touch also works (click fires). ok.
Frame:
```
function frame(now){
requestAnimationFrame(frame);
fit();
let dt=(now-lastFrame)/1000; lastFrame=now;
if(dt>0.05) dt=0.05;
if(mode==='play'||mode==='demo'){
acc+=dt*1000;
let steps=0;
while(acc>=tickMs && steps<4){ acc-=tickMs; tick(); steps++; if(mode!=='play'&&mode!=='demo') { acc=0; break; } }
}
const tt=(mode==='play'||mode==='demo') ? Math.min(1, acc/tickMs) : Math.min(1, acc/tickMs);
```
Hmm during over/paused acc stays; tt constant. But after die, acc could be near tickMs → tt~1 static anyway. Just clamp.
```
// fx updates
updateFx(dt);
draw(now, tt);
}
```
updateFx: particles physics, rings t, floaters t, filter expired.
draw(now, tt):
```
function draw(now, tt){
const S=cv.width/COLS;
ctx.setTransform(1,0,0,1,0,0);
// shake
let shk=0;
if(dying){ const k=1-(now-deathAt)/450; if(k>0) shk=k*k; }
if(shk>0){
const m=0.5*S*shk;
cv.style.transform=`translate(${(Math.random()*2-1)*m}px,${(Math.random()*2-1)*m}px)`;
} else cv.style.transform='';
// board
ctx.fillStyle=C.board; ctx.fillRect(0,0,cv.width,cv.height);
ctx.fillStyle=C.cellB;
for(let y=0;y<ROWS;y++)for(let x=0;x<COLS;x++) if((x+y)&1) ctx.fillRect(x*S-0.5,y*S-0.5,S+1,S+1);
```
Hmm -0.5 to overlap left/top too. Fine.
Wait: fillRect with y*S-0.5 at y=0 → -0.5 leaves top row seam? fill covers -0.5..S+0.5 fine.
```
drawFood(now,S);
drawSnake(now,tt,S);
drawFx(S);
// wall frame
ctx.strokeStyle='rgba(243,235,218,0.14)'; ctx.lineWidth=Math.max(1.5, S*0.05);
ctx.strokeRect(ctx.lineWidth, ctx.lineWidth, cv.width-ctx.lineWidth*2, cv.height-ctx.lineWidth*2);
```
Hmm stroke inside. Actually simpler: strokeRect(0,0,W,H) half-clipped... use inset 1px: strokeRect(1,1,W-2,H-2) lineWidth 2. ok.
drawFood:
```
function drawFood(now,S){
if(!food) return;
const fx=(food.x+0.5)*S, fy=(food.y+0.5)*S;
const ph=(now%1100)/1100;
ctx.strokeStyle=`rgba(243,235,218,${(0.4*(1-ph)).toFixed(3)})`;
ctx.lineWidth=Math.max(1,S*0.045);
ctx.beginPath(); ctx.arc(fx,fy,(0.34+ph*0.4)*S,0,Math.PI*2); ctx.stroke();
ctx.fillStyle=C.ivory;
ctx.beginPath(); ctx.arc(fx,fy,(0.27+0.025*Math.sin(now/260))*S,0,Math.PI*2); ctx.fill();
ctx.fillStyle='rgba(255,255,255,0.55)';
ctx.beginPath(); ctx.arc(fx-0.08*S,fy-0.09*S,0.08*S,0,Math.PI*2); ctx.fill();
}
```
drawSnake:
```
function drawSnake(now,tt,S){
const n=snake.length; if(!n) return;
const pts=new Array(n);
for(let i=0;i<n;i++){
const c=snake[i], p=prevSnake[Math.min(i,prevSnake.length-1)]||c;
pts[i]={x:(p.x+(c.x-p.x)*tt+0.5)*S, y:(p.y+(c.y-p.y)*tt+0.5)*S};
}
const flash=dying?Math.min(1,(now-deathAt)/280):0;
const bodyCol=mix('#E4572E','#F3EBDA',flash*0.85);
const headCol=mix('#C8431F','#F3EBDA',flash*0.85);
// tongue (under head)
let heading={x:dir.x,y:dir.y};
if(n>1){
const dx=pts[0].x-pts[1].x, dy=pts[0].y-pts[1].y, L=Math.hypot(dx,dy);
if(L>0.01) heading={x:dx/L,y:dy/L};
}
if(!dying){
const tp=(now%1700);
if(tp<240){
const k=Math.sin(tp/240*Math.PI);
const bx=pts[0].x+heading.x*0.28*S, by=pts[0].y+heading.y*0.28*S;
const tx=pts[0].x+heading.x*(0.55+0.3*k)*S, ty=pts[0].y+heading.y*(0.55+0.3*k)*S;
ctx.strokeStyle=C.tongue; ctx.lineWidth=Math.max(1.5,S*0.07); ctx.lineCap='round';
ctx.beginPath(); ctx.moveTo(bx,by); ctx.lineTo(tx,ty);
const mx=(bx+tx)/2, my=(by+ty)/2;
ctx.moveTo(mx,my); ctx.lineTo(mx+ (heading.x*0.12 + -heading.y*0.1)*S, my+(heading.y*0.12 + heading.x*0.1)*S);
ctx.moveTo(mx,my); ctx.lineTo(mx+ (heading.x*0.12 + heading.y*0.1)*S, my+(heading.y*0.12 - heading.x*0.1)*S);
ctx.stroke();
}
}
// body
ctx.lineCap='round'; ctx.lineJoin='round';
for(let i=n-2;i>=0;i--){
const w=(0.72-(0.30)*(i/Math.max(1,n-1)))*S;
ctx.strokeStyle=bodyCol; ctx.lineWidth=w;
ctx.beginPath(); ctx.moveTo(pts[i+1].x,pts[i+1].y); ctx.lineTo(pts[i].x,pts[i].y); ctx.stroke();
}
// spine
if(n>2 && flash<0.5){
ctx.strokeStyle=C.spine; ctx.lineWidth=Math.max(1.5,S*0.12);
ctx.beginPath(); ctx.moveTo(pts[1].x,pts[1].y);
for(let i=2;i<n;i++) ctx.lineTo(pts[i].x,pts[i].y);
ctx.stroke();
}
```
Hmm spine along pts[1..] — but body taper means spine (fixed width) sits inside thinner tail? tail width 0.42 vs spine 0.12 fine. Start spine at pts[1] to avoid crossing head weirdly — actually include pts[0]? Head circle covers. Start at 0 fine too; I'll start at 0 but spine drawn before head circle → covered. Order: tongue, body, spine, head, eyes. Wait spine over body: yes.
Hmm spine over body but body drawn tail→head, spine after — ok.
```
// head
ctx.fillStyle=headCol;
ctx.beginPath(); ctx.arc(pts[0].x,pts[0].y,0.46*S,0,Math.PI*2); ctx.fill();
// eyes
const perp={x:-heading.y,y:heading.x};
for(const s of [-1,1]){
const ex=pts[0].x+heading.x*0.13*S+perp.x*0.18*S*s;
const ey=pts[0].y+heading.y*0.13*S+perp.y*0.18*S*s;
if(dying){
// X eyes
ctx.strokeStyle=C.ink; ctx.lineWidth=Math.max(1.5,S*0.05); ctx.lineCap='round';
const r=0.09*S;
ctx.beginPath();
ctx.moveTo(ex-r,ey-r); ctx.lineTo(ex+r,ey+r);
ctx.moveTo(ex+r,ey-r); ctx.lineTo(ex-r,ey+r);
ctx.stroke();
} else {
ctx.fillStyle=C.ivory;
ctx.beginPath(); ctx.arc(ex,ey,0.115*S,0,Math.PI*2); ctx.fill();
ctx.fillStyle=C.ink;
ctx.beginPath(); ctx.arc(ex+heading.x*0.045*S,ey+heading.y*0.045*S,0.055*S,0,Math.PI*2); ctx.fill();
}
}
}
```
drawFx:
```
function drawFx(S){
for(const p of particles){
const k=p.life/p.max, a=1-k;
if(p.streak){
ctx.strokeStyle=hexA(p.c,a); ...
```
Simpler: use globalAlpha.
```
for(const p of particles){
const k=p.life/p.max;
ctx.globalAlpha=1-k;
ctx.fillStyle=p.c;
if(p.streak){
ctx.strokeStyle=p.c; ctx.lineWidth=Math.max(1,0.05*S);
ctx.beginPath(); ctx.moveTo(p.x*S,p.y*S); ctx.lineTo((p.x-p.vx*0.045)*S,(p.y-p.vy*0.045)*S); ctx.stroke();
} else {
ctx.beginPath(); ctx.arc(p.x*S,p.y*S,p.r*(1-k*0.4)*S,0,Math.PI*2); ctx.fill();
}
}
for(const r of rings){
const k=r.t/0.45;
ctx.globalAlpha=0.5*(1-k);
ctx.strokeStyle='#F3EBDA'; ctx.lineWidth=Math.max(1,0.07*S);
ctx.beginPath(); ctx.arc(r.x*S,r.y*S,(0.25+k*1.6)*S,0,Math.PI*2); ctx.stroke();
}
for(const f of floaters){
const k=f.t/0.9;
ctx.globalAlpha=1-k;
ctx.fillStyle='#FFB38A';
ctx.font=`700 ${Math.round(0.55*S)}px ui-monospace, Menlo, Consolas, monospace`;
ctx.textAlign='center'; ctx.textBaseline='middle';
ctx.fillText('+10', f.x*S, (f.y-k*1.1)*S);
}
ctx.globalAlpha=1;
}
```
'#FFB38A' warm light coral — readable on dark. ok.
updateFx:
```
function updateFx(dt){
for(const p of particles){ p.life+=dt; p.x+=p.vx*dt; p.y+=p.vy*dt; p.vy+=6.5*dt; p.vx*=Math.pow(0.6,dt)? }
```
Damping: p.vx*=1-1.5*dt approx. Fine:
```
particles=particles.filter(p=>p.life<p.max);
for(const r of rings) r.t+=dt;
rings=rings.filter(r=>r.t<0.45);
for(const f of floaters) f.t+=dt;
floaters=floaters.filter(f=>f.t<0.9);
}
```
burst:
```
function burst(x,y){
const cx=x+0.5, cy=y+0.5;
for(let i=0;i<16;i++){
const a=Math.random()*Math.PI*2, sp=2+Math.random()*3.6;
particles.push({x:cx,y:cy,vx:Math.cos(a)*sp,vy:Math.sin(a)*sp-1.4,life:0,max:0.45+Math.random()*0.4,r:0.05+Math.random()*0.08,c:Math.random()<0.55?'#F3EBDA':'#FF7A4D',streak:Math.random()<0.35});
}
rings.push({x:cx,y:cy,t:0});
floaters.push({x:cx,y:cy-0.3,t:0});
}
```
tick eat also blip(660, 990, 0.09,'square',0.05); die blip(220,90,0.3,'sawtooth',0.06) — but demo silent (userGestured false). Good.
Init:
```
resetGame();
showReady();
requestAnimationFrame(frame);
```
fit called in frame. Also initial fit before first paint maybe; frame does it.
One concern: `cv.getBoundingClientRect()` before layout with CSS — fine after DOM ready; script at end of body.
**Fit cap dpr 2** to keep perf.
**updateHUD(false) initial.**
Also when demo dies and timer resets — during that 900ms mode still 'demo' with dying snake; ticking stopped? mode 'demo' still accumulates → tick() would run again! Bug: after die in demo, ticks continue moving snake into wall repeatedly. die() sets dying=true; tick() must early-return if dying: `if(dying) return;` at top of tick. But then acc keeps accumulating... fine, tick returns. Also for mode 'over', accumulation stops (mode not play/demo). For demo dying, keep guard. Add `if(dying) return;` in tick. ✓ And resetGame clears dying.
Also in demo, aiSteer called at tick start — with dying guard before it. ✓
Also on death, acc may keep growing during the 900ms freeze; on reset acc=0. ✓
**Paused rendering:** acc frozen since we don't accumulate in paused; tt constant mid-move — snake frozen mid-cell. Fine.
**Edge: prevSnake shorter than snake after reset from a grown state — resetGame rebuilds both len 4. ✓**
**overlay z / pointer:** overlay has pointer-events auto always (even hidden? visibility hidden disables). Show state clickable. When hidden, canvas taps handled. ✓
**Chip CSS:** `.chip{display:inline-block; margin:6px 0 2px; padding:4px 12px; background:var(--coral); color:#FFF6E8; font-family:mono; font-size:11px; letter-spacing:.2em; text-transform:uppercase; border-radius:999px;}`
**ov-stats:** flex row gap 26px; each: span label small muted-ivory, b mono 34px coral / ivory. Score b coral, best b ivory.
**ov-kicker:** mono 11px letterspace uppercase, rgba ivory .6.
**ov-title:** serif 46px, letter-spacing .18em, color ivory, margin.
**ov-action:** mono 14px ivory, margin-top 18px; maybe with a pulsing opacity animation (breathing) — subtle: `animation: breathe 2s ease-in-out infinite;` opacity .65↔1. Nice.
**ov-sub:** mono 12px rgba ivory .75; overlay kbds styled.
**Overlay em:** italic muted.
Footer hints em too.
**Value pop animation** defined.
**Brand h1 trailing letter-spacing gap** — acceptable.
Add small decorative? The header could include a tiny inline SVG snake mark... skip; typography carries it. Actually add a small coral square/dot before title? A tiny canvas-free glyph: `h1::after` coral square? Let me add `h1 span.dot`? Minor. I'll add a small coral rounded square via ::before inline-block 10px rotated 45°? Keep it: `<h1><i class="pip"></i>SNAKE</h1>` with pip = 10px coral rounded square, inline-block, margin-right 12px, transform rotate(45deg)? A diamond pip — printed-card motif. Cute, flat, not gradient. Yes.
**Check no emojis, no gradients, no blue/purple.** Arrows are unicode arrows (fine). ✓
**Score cap by winning:** if board full win(): set dying? Just: food=null; mode='over'; overShown after timeout; showOver(true). In tick eat branch: spawnFood may call win → after that code continues (score etc. fine). Ensure spawnFood sets food=null and win triggers die-like flow: I'll implement win() { dying=true; deathAt=performance.now(); mode='over'; overShown=false; setTimeout(showOver(true)... )} — showOver takes param; setTimeout(()=>{if(mode==='over'){showOver(true);overShown=true;}},600). And guard showOver default won=false.
**Font stack:** Georgia primary. Fine.
Now write the final file, carefully, complete.
Also `:root` scrollbar: body overflow hidden. `.wrap{padding:20px 12px}`.
Let me also double check hint kbd sizes on 375px: footer groups wrap — set flex-wrap wrap, justify center.
HUD at 375: brand h1 26px + stats — fits 345px? "SNAKE" tracked ~ 5 chars*(26*1.4) ≈ 190 + stats ~120 → ok. Reduce h1 to clamp(22px, 6vw, 30px). Sub line maybe hidden on very small? keep, it's small.
Stage width min(546px, 92vw, 100svh - 210px) — also cap for short screens.
Write it.
One more check on `fit()` — when CSS width uses calc(100svh - 210px) and svh unsupported? Chrome supports svh. Use fallback: also define width with vh fallback line before svh line (CSS fallback pattern). I'll write two width declarations.
Final code assembly. Also ensure `requestAnimationFrame(frame)` starts after functions defined — order in IIFE fine.
Let me also make sure `updateHUD` called on best change during demo: tick eat calls updateHUD(true) after best update. ✓
Potential issue: overlay 'show' class initial — showReady called at init. ✓ Screenshot at ~3s: demo running with veil + title visible.
Also: prevent context menu? no need.
Double-check the `while` tick loop breaking when mode changes (die in play sets mode='over') → condition `if(mode!=='play'&&mode!=='demo')` breaks and zeroes acc — but then tt uses acc=0 → snake pts computed with prev==cur anyway. fine.
Also `die()` during demo leaves mode 'demo', dying true — acc keeps accumulating each frame but tick early-returns. tt grows beyond 1? `Math.min(1, acc/tickMs)` clamped. ✓ After reset, acc=0, dying=false. ✓
queueDir during play only.
Check hitBody excludes tail always: when snake length 4 and target equals tail cell while eating? target==food cell which is never on body. ✓ non-eating: tail moves away so entering tail cell is legal. ✓ correct classic behavior.
Alright, also small: on restart via Space from over — startPlay resets. ✓
Also keydown repeat (held key) — queueDir dedups same dir. ✓
Write final. Also add `image-rendering` nothing needed.
Estimate CSS ~120 lines, JS ~330. Go.
For the pip diamond:
```
.pip{display:inline-block;width:11px;height:11px;background:var(--coral);border-radius:3px;transform:rotate(45deg);margin-right:14px;vertical-align: 2px? }
```
baseline tweak: vertical-align:1px.
Header alignment: brand and stats baseline aligned bottom.
Let me also give the stage a subtle "mat": stage border is on canvas; ok.
Write final answer now. Also the ov-title for "SNAKE" on ready duplicates header — that's fine (poster feel), or ready title could be "READY?" — I'll use big "SNAKE" on the veil since it's the hero moment, kicker explains demo.
JS detail — `hitsBody` used by aiSteer before unshift; consistent.
One subtle bug: in tick, after applying queue and setting prevSnake, if die occurs we return with prevSnake=snake clones — static render. But we already may have applied a new dir; irrelevant.
In demo, after reset the AI starts moving right. ✓
Food never on snake ✓ (free list).
Let me also handle devicePixelRatio changes (zoom) — fit each frame handles.
Compose final HTML.