DeepSeek V4.1 Flash 1017
by DeepSeekReasoningClean
Running live in a sandboxed frame. Click inside it before using the keyboard.
How it went
- Total time
- 1 min 33 s
- Cost
- $0.015
- Output tokens
- 30,811
- Spent thinking
- 24,033 tokens
- Speed
- 335.2 tok/s
- Console errors
- 0
- First token after
- 878 ms
- First visible text after
- 1 min 15 s
- Prompt tokens
- 518
- Code size
- 19.1 KB
- Finish reason
- stop
- Served by
- Ionstream
- Model id
deepseek/deepseek-v4.1-flash-1017- Reasoning sent
{"effort":"low"}- Max output
- 64,000 tokens
- Generated
- Nov 11, 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 · 748 lines
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Snake — Neon</title>
<style>
*{box-sizing:border-box}
html,body{height:100%}
body{
margin:0;
background:radial-gradient(1200px 700px at 50% -15%, #14402f 0%, #08201a 42%, #040b09 100%);
color:#dff5ea;
font-family:ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;
display:flex;align-items:center;justify-content:center;
overflow:hidden;
-webkit-font-smoothing:antialiased;
user-select:none;-webkit-user-select:none;
touch-action:manipulation;
}
.app{
width:100%;max-width:720px;
padding:16px;
display:flex;flex-direction:column;align-items:center;gap:14px;
}
.hud{
width:100%;
display:flex;align-items:center;justify-content:space-between;
}
.brand{
font-weight:800;font-size:14px;letter-spacing:.34em;
color:#7cf5b8;
text-shadow:0 0 20px rgba(90,255,170,.6);
}
.stats{display:flex;gap:10px}
.stat{
min-width:80px;
padding:6px 12px;
border-radius:12px;
text-align:center;
background:rgba(255,255,255,.045);
border:1px solid rgba(120,255,190,.16);
box-shadow:inset 0 1px 0 rgba(255,255,255,.05);
}
.stat .lbl{
display:block;font-size:9px;letter-spacing:.2em;color:#6ba794;margin-bottom:1px;
}
.stat .val{
display:inline-block;
font-size:18px;font-weight:800;line-height:1.1;
font-variant-numeric:tabular-nums;
color:#c9ffe6;
}
.stat .val.pop{animation:pop .32s ease}
@keyframes pop{
0%{transform:scale(1)}
40%{transform:scale(1.3);color:#b6ffd8}
100%{transform:scale(1)}
}
.stage{
position:relative;
width:min(72vh,100%);
aspect-ratio:1/1;
border-radius:20px;
box-shadow:
0 30px 70px -24px rgba(0,0,0,.95),
0 0 0 1px rgba(120,255,190,.12),
0 0 90px -30px rgba(60,255,170,.35);
}
canvas{display:block;width:100%;height:100%;border-radius:20px}
/* start hint */
.hint{
position:absolute;left:50%;bottom:7%;
transform:translateX(-50%);
padding:10px 18px;
border-radius:999px;
background:rgba(6,22,16,.82);
border:1px solid rgba(120,255,190,.3);
color:#c8ffe4;
font-size:13px;letter-spacing:.05em;white-space:nowrap;
box-shadow:0 0 34px rgba(80,255,170,.22);
backdrop-filter:blur(6px);-webkit-backdrop-filter:blur(6px);
pointer-events:none;
opacity:0;transition:opacity .35s ease;
animation:hintPulse 1.9s ease-in-out infinite;
}
.hint.show{opacity:1}
@keyframes hintPulse{
0%,100%{transform:translateX(-50%) scale(1)}
50%{transform:translateX(-50%) scale(1.045)}
}
/* overlays */
.overlay{
position:absolute;inset:0;border-radius:20px;
display:flex;flex-direction:column;align-items:center;justify-content:center;
text-align:center;padding:20px;
background:rgba(4,15,11,.76);
backdrop-filter:blur(7px);-webkit-backdrop-filter:blur(7px);
opacity:0;pointer-events:none;transition:opacity .25s ease;
}
.overlay.show{opacity:1;pointer-events:auto}
.ov-title{font-size:11px;letter-spacing:.45em;color:#7fd6b2;margin-bottom:8px}
.ov-score{
font-size:clamp(38px,9vh,60px);font-weight:800;line-height:1;
color:#93ffc8;
text-shadow:0 0 34px rgba(80,255,170,.65);
font-variant-numeric:tabular-nums;
}
.ov-sub{margin-top:16px;font-size:13px;color:#9ec9b8}
.ov-sub b{color:#d7ffee;font-weight:700}
.foot{
font-size:11px;letter-spacing:.14em;color:#4e7d6e;text-align:center;
}
@media (max-width:520px){
.brand{font-size:12px;letter-spacing:.24em}
.stat{min-width:66px;padding:5px 9px}
.stat .val{font-size:15px}
.foot{font-size:10px;letter-spacing:.1em}
.hint{font-size:11px;padding:8px 14px}
}
</style>
</head>
<body>
<div class="app">
<div class="hud">
<div class="brand">SNAKE</div>
<div class="stats">
<div class="stat"><span class="lbl">SCORE</span><span class="val" id="score">0</span></div>
<div class="stat"><span class="lbl">BEST</span><span class="val" id="best">0</span></div>
</div>
</div>
<div class="stage" id="stage">
<canvas id="game"></canvas>
<div class="hint show" id="hint">Click or press any key to start</div>
<div class="overlay" id="overlay">
<div class="ov-title" id="ovTitle">GAME OVER</div>
<div class="ov-score" id="ovScore">0</div>
<div class="ov-sub" id="ovSub">Press <b>Space</b> to restart</div>
</div>
</div>
<div class="foot">ARROWS / WASD — MOVE · P — PAUSE · SPACE — RESTART</div>
</div>
<script>
(function(){
'use strict';
/* ------------------------------ setup ------------------------------ */
var canvas = document.getElementById('game');
var ctx = canvas.getContext('2d');
var stageEl = document.getElementById('stage');
var scoreEl = document.getElementById('score');
var bestEl = document.getElementById('best');
var hintEl = document.getElementById('hint');
var overlayEl= document.getElementById('overlay');
var ovTitle = document.getElementById('ovTitle');
var ovScore = document.getElementById('ovScore');
var ovSub = document.getElementById('ovSub');
var GRID = 22;
var DIRS = [{x:0,y:-1},{x:1,y:0},{x:0,y:1},{x:-1,y:0}];
var cell = 24, size = 528, dpr = 1;
var snake = [], prevSnake = [], dir = {x:1,y:0}, queue = [];
var food = null, score = 0, best = 0;
var particles = [];
var state = 'demo'; // demo | playing | paused | over
var acc = 0, tInterp = 0, shake = 0, demoRespawnAt = 0;
var lastTime = performance.now();
function KEY(x,y){ return y*GRID + x; }
function rrect(c,x,y,w,h,r){
if (c.roundRect){ c.beginPath(); c.roundRect(x,y,w,h,r); return; }
c.beginPath();
c.moveTo(x+r,y);
c.arcTo(x+w,y,x+w,y+h,r);
c.arcTo(x+w,y+h,x,y+h,r);
c.arcTo(x,y+h,x,y,r);
c.arcTo(x,y,x+w,y,r);
c.closePath();
}
/* ------------------------------ sizing ------------------------------ */
function resize(){
var r = stageEl.getBoundingClientRect();
var w = Math.max(1, Math.round(r.width));
dpr = Math.min(window.devicePixelRatio || 1, 2);
var px = Math.round(w*dpr);
if (canvas.width !== px || canvas.height !== px){
canvas.width = px;
canvas.height = px;
}
size = w;
cell = size / GRID;
}
/* ------------------------------ game state ------------------------------ */
function reset(){
snake = [{x:4,y:11},{x:3,y:11},{x:2,y:11}];
prevSnake = snake.map(function(s){ return {x:s.x,y:s.y}; });
dir = {x:1,y:0};
queue = [];
score = 0;
particles = [];
acc = 0;
tInterp = 0;
food = null;
spawnFood();
updateHUD();
}
function spawnFood(){
var occ = {};
for (var i=0;i<snake.length;i++) occ[KEY(snake[i].x, snake[i].y)] = 1;
var free = [];
for (var y=0;y<GRID;y++){
for (var x=0;x<GRID;x++){
if (!occ[KEY(x,y)]) free.push({x:x,y:y});
}
}
food = free.length ? free[(Math.random()*free.length)|0] : null;
}
function updateHUD(){
scoreEl.textContent = score;
bestEl.textContent = best;
}
function popScore(){
scoreEl.classList.remove('pop');
void scoreEl.offsetWidth;
scoreEl.classList.add('pop');
}
function stepMs(){
return Math.max(72, 120 - Math.floor(score/50)*8);
}
function step(){
/* ----- direction ----- */
if (state === 'demo'){
var d = aiChoose();
if (d) dir = d;
} else {
while (queue.length){
var nd = queue.shift();
if (nd.x === -dir.x && nd.y === -dir.y) continue;
if (nd.x === dir.x && nd.y === dir.y) continue;
dir = nd;
break;
}
}
var head = snake[0];
var nx = head.x + dir.x;
var ny = head.y + dir.y;
/* wall */
if (nx < 0 || ny < 0 || nx >= GRID || ny >= GRID){ die(); return; }
var willEat = !!(food && nx === food.x && ny === food.y);
var limit = willEat ? snake.length : snake.length - 1;
for (var i=0;i<limit;i++){
if (snake[i].x === nx && snake[i].y === ny){ die(); return; }
}
prevSnake = snake.map(function(s){ return {x:s.x,y:s.y}; });
snake.unshift({x:nx, y:ny});
if (willEat){
score += 10;
if (score > best){ best = score; }
burst((food.x+0.5)*cell, (food.y+0.5)*cell);
spawnFood();
updateHUD();
popScore();
} else {
snake.pop();
}
}
function die(){
shake = 13;
if (state === 'demo'){
state = 'over';
demoRespawnAt = performance.now() + 1500;
return;
}
state = 'over';
showGameOver();
}
function showGameOver(){
ovTitle.textContent = 'GAME OVER';
ovScore.textContent = score;
ovScore.style.display = '';
ovSub.innerHTML = 'Press <b>Space</b> or <b>Enter</b> to restart';
overlayEl.classList.add('show');
}
function showPause(){
ovTitle.textContent = 'PAUSED';
ovScore.textContent = score;
ovScore.style.display = '';
ovSub.innerHTML = 'Press <b>P</b> to resume';
overlayEl.classList.add('show');
}
function togglePause(){
if (state === 'playing'){
state = 'paused';
showPause();
} else if (state === 'paused'){
state = 'playing';
overlayEl.classList.remove('show');
}
}
function startPlaying(){
state = 'playing';
demoRespawnAt = 0;
hintEl.classList.remove('show');
overlayEl.classList.remove('show');
reset();
}
/* ------------------------------ particles ------------------------------ */
function burst(px, py){
for (var i=0;i<24;i++){
var a = Math.random()*Math.PI*2;
var sp = 70 + Math.random()*210;
var life = 0.45 + Math.random()*0.4;
particles.push({
x:px, y:py,
vx:Math.cos(a)*sp,
vy:Math.sin(a)*sp,
life:life, max:life,
r:1.4 + Math.random()*2.6,
hue: 12 + Math.random()*56
});
}
for (var j=0;j<10;j++){
var ang = Math.random()*Math.PI*2;
var s2 = 40 + Math.random()*130;
var l2 = 0.35 + Math.random()*0.35;
particles.push({
x:px, y:py,
vx:Math.cos(ang)*s2,
vy:Math.sin(ang)*s2,
life:l2, max:l2,
r:1.2 + Math.random()*1.8,
hue: 140 + Math.random()*30
});
}
}
function updateParticles(dt){
for (var i=particles.length-1;i>=0;i--){
var p = particles[i];
p.life -= dt;
if (p.life <= 0){ particles.splice(i,1); continue; }
p.x += p.vx*dt;
p.y += p.vy*dt;
var f = Math.pow(0.05, dt);
p.vx *= f; p.vy *= f;
}
}
function drawParticles(){
if (!particles.length) return;
ctx.save();
ctx.globalCompositeOperation = 'lighter';
for (var i=0;i<particles.length;i++){
var p = particles[i];
var a = p.life / p.max;
if (a <= 0) continue;
ctx.globalAlpha = a*a;
ctx.fillStyle = 'hsl(' + p.hue.toFixed(0) + ' 100% 62%)';
ctx.beginPath();
ctx.arc(p.x, p.y, p.r*a + 0.4, 0, Math.PI*2);
ctx.fill();
}
ctx.restore();
}
/* ------------------------------ AI ------------------------------ */
function bfsPath(start, goal, blocked){
var sk = KEY(start.x,start.y);
var gk = KEY(goal.x,goal.y);
var parent = {};
parent[sk] = null;
var q = [start];
var h = 0;
while (h < q.length){
var cur = q[h++];
var ck = KEY(cur.x,cur.y);
if (ck === gk){
var path = [];
var node = cur, k = ck;
while (node){
path.push(node);
var p = parent[k];
node = p;
k = p ? KEY(p.x,p.y) : -1;
}
return path.reverse();
}
for (var i=0;i<4;i++){
var d = DIRS[i];
var nx = cur.x + d.x, ny = cur.y + d.y;
if (nx<0||ny<0||nx>=GRID||ny>=GRID) continue;
var nk = KEY(nx,ny);
if (nk in parent) continue;
if (blocked[nk] && nk !== gk) continue;
parent[nk] = cur;
q.push({x:nx,y:ny});
}
}
return null;
}
function floodArea(start, blocked){
var seen = {};
seen[KEY(start.x,start.y)] = 1;
var q = [start];
var h = 0, count = 0;
while (h < q.length){
var c = q[h++];
count++;
for (var i=0;i<4;i++){
var d = DIRS[i];
var nx = c.x + d.x, ny = c.y + d.y;
if (nx<0||ny<0||nx>=GRID||ny>=GRID) continue;
var nk = KEY(nx,ny);
if (seen[nk] || blocked[nk]) continue;
seen[nk] = 1;
q.push({x:nx,y:ny});
}
}
return count;
}
function aiChoose(){
var head = snake[0];
var bodySet = {};
for (var i=0;i<snake.length-1;i++) bodySet[KEY(snake[i].x, snake[i].y)] = 1;
var cands = [];
for (var k=0;k<4;k++){
var d = DIRS[k];
if (d.x === -dir.x && d.y === -dir.y) continue;
var nx = head.x + d.x, ny = head.y + d.y;
if (nx<0||ny<0||nx>=GRID||ny>=GRID) continue;
if (bodySet[KEY(nx,ny)]) continue;
cands.push(d);
}
if (!cands.length) return null;
function areaAfter(d){
var nh = {x:head.x+d.x, y:head.y+d.y};
var nb = [nh];
for (var i=0;i<snake.length-1;i++) nb.push(snake[i]);
var obs = {};
for (var j=0;j<nb.length-1;j++) obs[KEY(nb[j].x, nb[j].y)] = 1;
return floodArea(nh, obs);
}
if (food){
var path = bfsPath(head, food, bodySet);
if (path && path.length > 1){
var s = path[1];
var nd = {x:s.x-head.x, y:s.y-head.y};
var ok = false;
for (var c=0;c<cands.length;c++){
if (cands[c].x === nd.x && cands[c].y === nd.y){ ok = true; break; }
}
if (ok && areaAfter(nd) >= snake.length) return nd;
}
}
var bestD = null, bestA = -1;
for (var m=0;m<cands.length;m++){
var a = areaAfter(cands[m]);
if (a > bestA){ bestA = a; bestD = cands[m]; }
}
return bestD || cands[0];
}
/* ------------------------------ drawing ------------------------------ */
function drawFood(now){
var fx = (food.x+0.5)*cell;
var fy = (food.y+0.5)*cell;
var pulse = 1 + Math.sin(now/230)*0.09;
var r = cell*0.31*pulse;
var glow = ctx.createRadialGradient(fx,fy,0,fx,fy,cell*1.3);
glow.addColorStop(0,'rgba(255,95,125,0.34)');
glow.addColorStop(1,'rgba(255,95,125,0)');
ctx.fillStyle = glow;
ctx.beginPath(); ctx.arc(fx,fy,cell*1.3,0,Math.PI*2); ctx.fill();
var g = ctx.createRadialGradient(fx-cell*0.1, fy-cell*0.12, cell*0.03, fx, fy, r*1.2);
g.addColorStop(0,'#ffe6ac');
g.addColorStop(0.45,'#ff6b6b');
g.addColorStop(1,'#d81e4a');
ctx.fillStyle = g;
ctx.beginPath(); ctx.arc(fx,fy,r,0,Math.PI*2); ctx.fill();
ctx.fillStyle = 'rgba(255,255,255,0.7)';
ctx.beginPath(); ctx.arc(fx - r*0.33, fy - r*0.36, r*0.2, 0, Math.PI*2); ctx.fill();
}
function drawSnake(it){
var n = snake.length;
if (!n) return;
var pts = [];
for (var i=0;i<n;i++){
var c = snake[i];
var p = (i < prevSnake.length) ? prevSnake[i] : prevSnake[prevSnake.length-1];
var px = p ? p.x : c.x;
var py = p ? p.y : c.y;
pts.push({
x: (px + (c.x-px)*it + 0.5) * cell,
y: (py + (c.y-py)*it + 0.5) * cell
});
}
var lw = cell * 0.80;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
/* body: tail -> head */
for (var j=n-1;j>=0;j--){
var a = pts[j];
var b = pts[j+1] || pts[j];
var u = n > 1 ? j/(n-1) : 0;
var hue = 145 + 30*u;
var sat = 88 - 22*u;
var lig = 62 - 22*u;
ctx.strokeStyle = 'hsl(' + hue.toFixed(1) + ' ' + sat.toFixed(1) + '% ' + lig.toFixed(1) + '%)';
ctx.lineWidth = lw * (1 - 0.22*u);
ctx.beginPath();
ctx.moveTo(a.x, a.y);
ctx.lineTo(b.x, b.y);
ctx.stroke();
}
/* head */
var h = pts[0];
ctx.save();
ctx.shadowColor = 'rgba(140,255,195,0.85)';
ctx.shadowBlur = cell*0.9;
ctx.fillStyle = 'hsl(148 92% 68%)';
ctx.beginPath();
ctx.arc(h.x, h.y, lw*0.56, 0, Math.PI*2);
ctx.fill();
ctx.restore();
/* eyes */
var dx = dir.x, dy = dir.y;
var px2 = -dy, py2 = dx;
var fwd = cell*0.17, side = cell*0.17;
for (var s=-1;s<=1;s+=2){
var ex = h.x + dx*fwd + px2*side*s;
var ey = h.y + dy*fwd + py2*side*s;
ctx.fillStyle = '#ffffff';
ctx.beginPath(); ctx.arc(ex, ey, cell*0.115, 0, Math.PI*2); ctx.fill();
ctx.fillStyle = '#06231a';
ctx.beginPath();
ctx.arc(ex + dx*cell*0.035, ey + dy*cell*0.035, cell*0.058, 0, Math.PI*2);
ctx.fill();
}
}
function draw(now, it){
var W = size;
ctx.setTransform(dpr,0,0,dpr,0,0);
ctx.clearRect(0,0,W,W);
ctx.save();
if (shake > 0.4){
ctx.translate((Math.random()-0.5)*shake, (Math.random()-0.5)*shake);
}
rrect(ctx,0,0,W,W,20);
ctx.save();
ctx.clip();
var bg = ctx.createRadialGradient(W*0.5, W*0.28, W*0.04, W*0.5, W*0.55, W*0.92);
bg.addColorStop(0,'#0f2f23');
bg.addColorStop(1,'#05100d');
ctx.fillStyle = bg;
ctx.fillRect(0,0,W,W);
/* checker */
ctx.fillStyle = 'rgba(255,255,255,0.017)';
for (var y=0;y<GRID;y++){
for (var x=0;x<GRID;x++){
if ((x+y)&1) continue;
ctx.fillRect(x*cell, y*cell, cell, cell);
}
}
/* grid lines */
ctx.strokeStyle = 'rgba(130,255,200,0.032)';
ctx.lineWidth = 1;
ctx.beginPath();
for (var i=1;i<GRID;i++){
var o = i*cell;
ctx.moveTo(o,0); ctx.lineTo(o,W);
ctx.moveTo(0,o); ctx.lineTo(W,o);
}
ctx.stroke();
if (food) drawFood(now);
drawSnake(it);
drawParticles();
ctx.restore(); /* clip */
rrect(ctx,0.75,0.75,W-1.5,W-1.5,20);
ctx.strokeStyle = 'rgba(140,255,200,0.16)';
ctx.lineWidth = 1.5;
ctx.stroke();
ctx.restore();
}
/* ------------------------------ main loop ------------------------------ */
function loop(now){
requestAnimationFrame(loop);
var dt = now - lastTime;
if (dt > 100) dt = 100;
if (dt < 0) dt = 0;
lastTime = now;
var sm = stepMs();
if (state === 'playing' || state === 'demo'){
acc += dt;
var guard = 0;
while (acc >= sm && guard++ < 5){
acc -= sm;
step();
if (state !== 'playing' && state !== 'demo'){ acc = 0; break; }
}
tInterp = Math.min(acc / sm, 1);
} else if (state === 'over'){
acc = 0;
tInterp = 1;
}
if (state === 'over' && demoRespawnAt && now >= demoRespawnAt){
demoRespawnAt = 0;
state = 'demo';
reset();
hintEl.classList.add('show');
}
if (shake > 0) shake = Math.max(0, shake - dt*0.022);
updateParticles(dt/1000);
draw(now, tInterp);
}
/* ------------------------------ input ------------------------------ */
function queueDir(d){
if (queue.length < 3) queue.push(d);
}
function onKey(e){
var k = e.key;
var lower = (k && k.length === 1) ? k.toLowerCase() : k;
if (k === 'ArrowUp' || k === 'ArrowDown' || k === 'ArrowLeft' || k === 'ArrowRight' ||
k === ' ' || k === 'Enter'){
e.preventDefault();
}
if (state === 'demo'){
startPlaying();
return;
}
if (state === 'over'){
if (k === ' ' || k === 'Enter'){
e.preventDefault();
startPlaying();
}
return;
}
if (lower === 'p'){
togglePause();
return;
}
if (state === 'paused'){
if (k === ' ' || k === 'Enter'){ togglePause(); }
return;
}
/* playing */
if (e.repeat) return;
var map = {
'ArrowUp':[0,-1], 'ArrowDown':[0,1], 'ArrowLeft':[-1,0], 'ArrowRight':[1,0],
'w':[0,-1], 's':[0,1], 'a':[-1,0], 'd':[1,0]
};
var m = map[k] || map[lower];
if (m) queueDir({x:m[0], y:m[1]});
}
document.addEventListener('keydown', onKey, {passive:false});
document.addEventListener('pointerdown', function(){
if (state === 'demo' || state === 'over'){
startPlaying();
} else if (state === 'paused'){
togglePause();
}
});
/* ------------------------------ boot ------------------------------ */
if (window.ResizeObserver){
new ResizeObserver(resize).observe(stageEl);
}
window.addEventListener('resize', resize);
window.addEventListener('orientationchange', function(){ setTimeout(resize, 80); });
resize();
reset();
state = 'demo';
hintEl.classList.add('show');
requestAnimationFrame(loop);
})();
</script>
</body>
</html>Reasoning trace (24,033 tokens)
Let me build a polished Snake game.
Requirements:
- Grid at least 20x20, centered.
- Arrow keys + WASD, quick successive presses honored (input queue), no reversal.
- Food +10, grow by one, food never on snake.
- Game over on wall/self, overlay with score, Space/Enter restart, P pause.
- Smooth movement (interpolate between grid cells), head with eyes facing direction, particle burst on eating.
- Score + best score of session.
- Demo mode: self-playing AI behind "Click or press a key to start" message.
Let me design.
Canvas size: responsive. Grid 22x22 cells. Cell size computed. Let's use a fixed logical size e.g. 22*28 = 616 px, with devicePixelRatio scaling. Actually responsive: compute cell size based on available viewport.
Let me do: canvas CSS size determined by container square; internal resolution = cssSize * dpr.
Simplest: set canvas logical size to a fixed 660x660 (22*30), then CSS max-width 100%, height auto. Then it scales down nicely. But crispness on hidpi — use dpr scaling of the drawing context? Since CSS scales, drawing at 660x660 and displaying at ~600px is fine-ish. Better: resize canvas backing store based on displayed size * dpr.
Let's implement:
```js
const GRID = 22;
function resize(){
const rect = wrap.getBoundingClientRect();
const size = Math.min(rect.width, rect.height);
...
}
```
Hmm, layout: page with header (score, best) and canvas centered. Use flex column, center.
Let me implement a fixed logical unit: cell = 28 at max. Board size = 22*28 = 616. Canvas element style width/height set by JS to fit viewport (min of available height and width). Backing store = size*dpr.
Simplify: use a container with `aspect-ratio: 1`, max sizes. Then in JS read canvas.clientWidth, set canvas.width = clientWidth * dpr.
Draw in logical units of CSS pixels: ctx.setTransform(dpr,0,0,dpr,0,0) then cell = clientWidth/GRID.
Game loop with requestAnimationFrame and interpolation.
Snake movement: step interval, e.g. 110ms at start, decreasing slightly with score? Maybe keep constant ~120ms for demo. Actually let's speed up slightly as score grows: interval = max(70, 130 - score*0.6). Hmm, for demo AI, faster is more fun. Let's do base 130ms.
Snake state: array of grid cells, head at index 0 (or tail?). Let's use array where index 0 is head... Actually for smooth interpolation it's easier to have segments as list of cells from head to tail, and interpolation factor t between previous step and current step.
Interpolation approach: keep `prevSnake` positions (cells) and `snake` cells. Render each segment at lerp(prev, cur, t). When growing, prev for the new tail... Standard approach: on step, newHead = head + dir; prevSnake = snake.slice(); snake.unshift(newHead); if(!grow) snake.pop(); For rendering, for segment i: prev position = prevSnake[i-1]? Hmm.
Let's think: after step, snake[i] for i in 0..n-1. Before step, snake_old[i] for i in 0..m-1 where m = n-1 (if not grown) or n (if grown... no, if grown snake length increases by 1, so m = n-1).
Rendering segment i at time t: position = lerp(snake_old[i-1] ?? snake_old[m-1], snake[i], t). Since snake[i] = snake_old[i-1] for i>=1, and snake[0] = new head. So it's a clean approach: each segment moves from the previous segment's old position to its current position. For the last segment when growing, the tail stays: snake_old[m-1] where m = snake_old.length, and snake[i] = ... hmm.
Simpler: prev array = snake before step (positions array of length L_old). After step, length L_new. For rendering segment i:
- from = prev[i] if i < prev.length else prev[prev.length-1]
- to = curr[i] if i < curr.length else curr[curr.length-1]
Wait is that right? After the step, curr[i] = prev[i-1] for i >= 1. So segment i moves from prev[i] to prev[i-1]. Yes! Because the snake shifts: segment i occupies the cell that segment i-1 used to occupy. So from = prev[i], to = curr[i]. For i = 0, from = prev[0], to = curr[0] = new head. For i = last, if the snake didn't grow, curr has length L and prev has length L; from = prev[L-1], to = curr[L-1]. OK.
If grew, curr.length = prev.length+1, so for i = prev.length (last index), from = prev[prev.length-1], to = curr[curr.length-1] = prev[prev.length-1]. Good — stays put.
So render: from = prev[i] ?? prev[prev.length-1], to = curr[i] ?? curr[curr.length-1]. But wait if snake didn't grow, prev.length == curr.length, so prev[i] always exists. Good. If grew, prev.length = curr.length-1, and for i = curr.length-1 prev[i] is undefined → use prev[prev.length-1].
Hmm but there's an issue: growth is usually done by not popping. In my step: unshift new head; if (!grow) pop. So curr.length = prev.length + 1 when growing. Yes.
But there's also wrap-around teleporting — no wrap, walls kill. Good.
Also need to handle the case where prev is empty at reset.
Rendering: draw rounded segments. Draw each segment as a rounded rect at interpolated position with size slightly less than cell, and a color gradient from head to tail. Plus head with eyes.
Alternative smoother: draw path with lineWidth = cell*0.8, round caps/joins, connecting the interpolated centers. That gives a nice smooth snake. But head/tail taper... Let's do a combination: draw the body as a thick rounded polyline path through interpolated centers, then draw head circle on top with eyes.
Actually a thick rounded polyline is very smooth and pretty. But there's a subtlety: the interpolated positions of consecutive segments — segment i is at lerp(prev[i], curr[i]); segment i-1 is lerp(prev[i-1], curr[i-1]). During the step, these are at distance = cell * (1 - t) + ... hmm, actually they're all moving together. Distance between consecutive interpolated points = cell. Fine, no gaps.
Wait: prev[i] and prev[i-1] are adjacent cells (distance cell). curr[i] and curr[i-1] also adjacent. Lerp both at same t → distance stays cell. Good.
But with corners, the polyline stays a valid path. Nice.
So: build array of points {x, y} in cell coordinates for the current interpolated frame. Then draw with lineWidth ≈ cell*0.78, round caps and joins. Add a gradient stroke? Could use a linear gradient along the body. Simpler: draw with a solid color and then draw the tail portion darker. Let's just use a gradient from head glow to tail.
Let's use ctx.createLinearGradient between head and tail points — might look weird on squiggly snakes but fine. Alternative: draw each segment separately with per-segment color (hue based on index). That's a nice look: from bright green head to teal tail. But drawing overlapping circles per segment leaves gaps when the snake turns? No, if we draw circles of radius ~cell*0.42 at each interpolated point with per-segment color, adjacent circles overlap slightly? Distance is cell, radius 0.42*cell → diameter 0.84 cell < cell. Gap. So draw circles at 0.55 radius... Use per-segment quads + circles: draw a thick line between consecutive points with per-segment color. Hmm, seams.
Easiest good-looking: draw the polyline path with round join/cap, using a single color, then overlay segments with per-segment color as thick lines from point i to i+1 with round caps. Actually per-segment line with round caps: line from p[i] to p[i+1] with lineWidth w and round caps → covers the corners. Union of all these with round caps is exactly the polyline stroke. So I can draw each segment with its own color. Seams at the caps are round so they overlap perfectly.
Order: draw from tail to head so head is on top. Colors: blend from a light green (head) to a darker teal (tail).
Actually with round caps, drawing consecutive segments overlapping creates a nice continuous look. Good.
Then head: draw a rounded shape / circle with eyes. Eyes positions: perpendicular offset from direction, and forward offset.
Direction for eyes: use actual movement direction (head - next segment), or the current direction. Use interpolated direction: head position minus second point. If t is small, second point ≈ prev[1] which is behind... Hmm. Better: use `dir` current direction of travel (the direction the head just moved), or for a smooth look use headPos - secondPointPos normalized.
At t=0, headPos = prev[0], secondPos = prev[1]. Direction = prev[0]-prev[1] = direction of previous move. At t=1, headPos = curr[0], second = curr[1] = prev[0], direction = new direction. Hmm, so it flips mid-step. That's fine but the eyes rotate abruptly at t=0.5. Alternatively use the current dir directly for the whole step (the head has already turned). At t=0 the head is at prev[0] but eyes already face the new direction. That's fine actually and it's what most snake games do. Let's use current dir.
Hmm, but at the moment of turning, the head is still at old cell and eyes point new direction — slightly off but fine.
Actually, better: interpolate the eye direction too. Meh, keep it simple: use the current dir.
Particles: on eat, spawn ~18 particles at the food position with random velocities, gravity-less, fading over ~500ms, drawn as small circles with a glow.
Food: pulsing circle with glow, maybe an apple-ish red/orange. Plus a subtle rotating ring.
Background: dark with a subtle grid, checkered pattern (alternating very subtle cell colors). Rounded border.
HUD: score and best above the canvas, in a nice font. Use system font stack.
Overlay: game over shows "GAME OVER", score, best, "Press Space / Enter to restart". Pause overlay "PAUSED".
Demo mode: AI plays. Starts with "Click or press a key to start" message. But the demo should be visible in action. Let's have the game run in demo mode automatically (AI playing) with an overlay message in the center-bottom or a banner. The overlay should be semi-transparent so the game is visible. Requirement: "Games must start in a self-playing demo mode (a simple AI plays) behind a 'Click or press a key to start' message". So show a message overlay while the AI plays.
Hmm, the overlay covers the board — make it a small pill-shaped banner at the bottom or center with backdrop blur. Let's do: a centered card with translucent background, containing "SNAKE" title? No, keep it: small pill "Click or press any key to start" with a subtle pulse, positioned near the bottom of the board, and demo AI plays visibly. Also the score during demo... hmm, best score is session. Let's have demo score not count toward best? Simpler: demo plays, score shown but when the player starts, the game resets. Actually best score of the session — let's track best from actual play. In demo, no best update. When player starts, reset.
Let's keep it simple: demo mode plays; the HUD shows "DEMO" indicator maybe. On first key/click → reset to player game, hide overlay.
AI: simple greedy pathing with safety:
- BFS to food avoiding the body (with tail moving out of the way) — that's the robust approach. A BFS on 22x22 grid is cheap.
- Fallback: any move that's safe (not into walls/body), prefer the one that maximizes free space (flood fill).
Let's implement BFS pathfinding to food, considering the body cells as obstacles (excluding tail if it's going to move... keep it simple: treat all body cells except the tail as obstacles). Then take the first step. If no path, choose the safe move with the largest flood-fill area.
That's a solid AI, rarely dies. For the demo it should look good but eventually it'll survive long — that's fine, it shows off. But if it never dies, the demo just keeps going forever, that's fine — it's visible in action.
Hmm, but the demo could get long snake. Fine.
Actually, maybe cap demo length? Not needed. But let's make the AI slightly imperfect so it eventually dies? No, keep it good.
Let's set the AI to have full BFS.
Now, step timing: `STEP_MS = 120` initially, maybe min 75. Let's just use constant 115ms for a nice look, or speed up slightly. I'll do: interval = Math.max(70, 120 - Math.floor(score/50)*8).
Let's keep at 110 for the demo default. I'll do 115.
Input queue: on keydown, push direction into queue (max 2-3). At each step, pop from the queue while the queued direction is opposite or the same as the current dir; if valid, set dir.
Represent dirs as {x,y}. Reverse check: dir.x + nd.x === 0 && dir.y + nd.y === 0.
Process: at step time, take the first queued dir that is valid; ignore invalid ones.
Let me write:
```js
let queue = [];
function queueDir(d){ if(queue.length<3) queue.push(d); }
function step(){
while(queue.length){
const nd = queue.shift();
if(nd.x === -dir.x && nd.y === -dir.y) continue; // ignore reversal
if(nd.x === dir.x && nd.y === dir.y) continue; // ignore same
dir = nd; break;
}
...
}
```
Hmm careful: if the queue's first item is invalid, we skip it and take the next valid. That honors "quick successive key presses must both be honored". With queue max 2: press up then left quickly → both processed, one per step. Good.
Wait but processing in a single step consumes multiple queue entries. Let's only make the direction change at the start of each step; the loop above will skip invalid entries and stop at the first valid. Since the first valid sets dir and we break, only one is consumed per step. Good, and two quick presses produce 2 steps of turns. Correct.
Now let me also handle: after a step, `dir` changed → but if the queue had 2 relevant items, we consume 1 per step. Good.
Game over: overlay, score. Space/Enter restarts to a fresh game (not demo). P pauses.
Also allow click/tap on canvas to start.
Let's also handle "click or press a key to start" → on first interaction, start playing. Also if game over, Space/Enter restarts.
Structure of state machine: 'demo' | 'playing' | 'paused' | 'over'.
On 'demo', AI decides direction each step.
Now let's write the code.
```html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Snake</title>
<style>...</style>
</head>
<body>
<div class="app">
<header>
<div class="brand">SNAKE</div>
<div class="scores">
<div class="score-box"><span class="label">SCORE</span><span id="score">0</span></div>
<div class="score-box"><span class="label">BEST</span><span id="best">0</span></div>
</div>
</header>
<div class="stage">
<canvas id="game"></canvas>
<div id="overlay" class="overlay">...</div>
</div>
<footer>Arrow keys / WASD to move · P to pause · Space to restart</footer>
</div>
```
Hmm, overlay: I'll render everything on canvas except the start prompt? Simpler to do all overlays in DOM. But pause/game-over overlays are DOM too. Let's do DOM overlays for start/gameover/pause, and canvas for the game. But the requirement says overlay showing score — DOM overlay is fine.
Actually rendering overlays on canvas is also fine and avoids layout issues. But DOM is easier for text styling. Let's do DOM.
Layout:
```css
html,body{height:100%;margin:0}
body{background: radial-gradient(...); color:#e8f6ef; font-family: ...; display:flex; align-items:center; justify-content:center; overflow:hidden;}
.app{ display:flex; flex-direction:column; gap:14px; padding:16px; align-items:center; }
.stage{ position:relative; width:min(80vh, 100%); aspect-ratio:1; ... }
```
Hmm, `width: min(80vh, 100%)` — with the header and footer, need to fit in 800px height. Let's compute: canvas side = min(100% of available width, 800 - header - footer - padding). Use `--board: min(76vh, 640px)` and on narrow screens width 100%.
CSS: `.stage{ width: min(76vh, 100%, 640px); aspect-ratio: 1; }` but 100% of the flex container width. Since .app is a column flex with align-items center, width:100% of .app... .app width is determined by content. Hmm, circular. Let's set `.app{width:100%; max-width:720px;}` and `.stage{width:min(76vh, 100%); aspect-ratio:1;}`. With max-width 720 and padding, the board could be 100% of ~688px. On 1280x800: 76vh = 608px. Good.
Also account for the header ~50px and footer ~30px and gaps. 76vh = 608, +50+30+32*2 padding = 752. OK fits in 800.
Hmm, let's use `min(70vh, 100%)` to be safe → 560. Fine.
Actually, let me build the stage height from remaining space: use `height: min(calc(100vh - 170px), 100%)` — meh. I'll just use vh math.
Canvas resize: use ResizeObserver on the stage, set canvas.width = Math.round(rect.width*dpr), height likewise.
Now the drawing.
```js
const GRID = 22;
let cell = 1; // css px per cell
let dpr = 1;
```
Draw function:
```js
function draw(now){
const t = ...; // interpolation
ctx.setTransform(dpr,0,0,dpr,0,0);
ctx.clearRect(0,0,size,size);
drawBoard();
drawFood(time);
drawSnake(t);
drawParticles(dt);
}
```
Board: fill with dark gradient, then draw a checkerboard of subtle cells, plus a border rounded rect.
Let's use a background: `#0a1a14` base. Alternating cells rgba(255,255,255,0.015). Plus vignette.
Snake colors: head #9dffb0? Let's do a neon green: head #7CFFB2, tail #14766b. Per-segment color via HSL interpolation: hue from 145 → 168, lightness 68 → 40, saturation 90 → 60.
Let's compute: for i from 0..n-1, u = i/(n-1); color = hsl(150 - 22*u, 85%, 62 - 26*u).
Hmm, use `hsl(${140 + 30*u}, ${85-20*u}%, ${64-24*u}%)`.
Head appears at u=0: hsl(140,85%,64%) bright green. Tail u=1: hsl(170,65%,40%) teal.
Plus glow: shadowBlur on the head.
Draw order: tail first (i from n-1 down to 1), then head. Wait, for the line segments, each segment i connects p[i] to p[i+1]? Let's draw segment between point i and i+1 with color of point i. Draw from tail to head so the head-end overlaps. Hmm, the head circle drawn after covers it.
Actually, since each segment is drawn with round caps, the color at the joint will be a mix. Whatever, it looks fine.
Let me reconsider: draw the polyline as a single path with round joins and a gradient stroke. Then per-segment color isn't needed. Use a gradient along the bounding box? It'd be a linear gradient, doesn't follow the snake but looks okay-ish.
I'll go with per-segment strokes. n is at most ~100, fine.
Alright.
Head: at p[0]. Draw a circle radius cell*0.44 with a lighter fill, plus eyes: two white circles with dark pupils, positioned perpendicular.
Eye direction: current dir (dx,dy). Perpendicular: (-dy, dx). Eye centers: headCenter + dir*0.14*cell ± perp*0.17*cell. Radius 0.115*cell white, pupil 0.055*cell dark offset in dir by 0.035*cell.
Hmm, if cell is small (e.g., 25px), eyes are tiny but fine.
Also a subtle tongue? Skip.
Food: pulsing. Draw a glowing circle with radius cell*0.3 + sin(t)*cell*0.03, radial gradient from #ffd166 to #ff4d6d, plus a glow shadow.
Particles: array of {x,y,vx,vy,life,maxLife,color,size}.
On eat: spawn 20 particles at food center in pixel coords. Update with dt, apply friction, draw with alpha = life/maxLife.
Now the main loop:
```js
let last = performance.now();
let acc = 0;
function loop(now){
const dt = Math.min(now - last, 100); last = now;
acc += dt;
if (state !== 'paused') {
while (acc >= stepMs) { acc -= stepMs; step(); }
} else { acc = 0; }
updateParticles(dt/1000);
render(acc / stepMs);
requestAnimationFrame(loop);
}
```
Careful: when state is 'over', don't step. When 'demo' or 'playing', step.
Interpolation t = acc/stepMs clamped 0..1. When paused, freeze.
Actually, freezing at pause: keep acc as is (0..stepMs). Fine.
Now step():
```js
function step(){
// direction
if (state === 'demo') { const d = aiChoose(); if (d) dir = d; }
else { while(queue.length){...} }
const head = snake[0];
const nx = head.x + dir.x, ny = head.y + dir.y;
// collision
if (nx<0||ny<0||nx>=GRID||ny>=GRID) return gameOver();
// self collision: check snake except tail (tail moves away) - but if growing it doesn't
...
}
```
Standard: check collision against snake cells excluding the last one (since it will move away), unless we're growing. Simplify: check against snake.slice(0, snake.length-1). Actually if the new head lands on the tail cell and the tail moves away, it's fine. Edge case: growing. Rare. Just use slice(0, len-1).
Hmm, but if the food is at the tail cell... can't be, food never spawns on the snake.
```js
const eat = food && nx===food.x && ny===food.y;
prev = snake.map(s=>({x:s.x,y:s.y}));
snake.unshift({x:nx,y:ny});
if (!eat) snake.pop();
else { score += 10; spawnFood(); burst(food...); }
```
Wait, order: we need to burst at the food position before spawning the new one. Let's capture: eat → burst at {x:food.x, y:food.y}, then spawnFood().
Also, prev: before mutation, prev = snake.slice() (deep copy of objects — since we unshift new object and pop, the existing objects aren't mutated, so a shallow copy is fine). Actually `snake.unshift({x,y})` creates a new object; the old objects stay. So `prev = snake.slice()` is enough (shallow). But then prev.length = old length. Good.
Score update and best.
Also update stepMs based on score.
Game over: state = 'over', show overlay.
Now the AI:
```js
function aiChoose(){
const head = snake[0];
const dirs = [{x:1,y:0},{x:-1,y:0},{x:0,y:1},{x:0,y:-1}];
const blocked = new Set(); // "x,y"
// all snake cells except tail (tail will move)
for (let i=0;i<snake.length-1;i++) blocked.add(snake[i].x+','+snake[i].y);
// BFS from head to food
...
}
```
Careful: the tail cell is walkable (it will move). But if the head is adjacent to the tail... whatever.
BFS:
```js
function bfsPath(from, target, blocked){
const key = (x,y)=>y*GRID+x;
const prevArr = new Int32Array(GRID*GRID).fill(-1);
const seen = new Uint8Array(GRID*GRID);
const q = [from];
seen[key(from.x,from.y)] = 1;
let qi = 0;
while(qi < q.length){
const c = q[qi++];
if (c.x===target.x && c.y===target.y){ // reconstruct
let cur = c, path=[];
while (cur) { path.push(cur); cur = prevArr[key(cur.x,cur.y)] >= 0 ? q[prevArr[...]] : null }
}
}
}
```
Simpler: store parent map as a Map of key→parent.
Let me just do BFS with a plain array and a Map.
```js
function bfs(start, goal, blocked){
const startK = start.y*GRID+start.x;
const goalK = goal.y*GRID+goal.x;
const q = [start];
const parent = new Map([[startK, -1]]);
let head = 0;
while (head < q.length){
const cur = q[head++];
const ck = cur.y*GRID+cur.x;
if (ck === goalK){
// reconstruct
let path = [];
let k = ck;
let node = cur;
while (k !== -1){
path.push(node);
const p = parent.get(k);
if (p === -1) break;
node = p; k = p.y*GRID+p.x;
}
return path.reverse();
}
for (const d of DIRS){
const nx = cur.x+d.x, ny = cur.y+d.y;
if (nx<0||ny<0||nx>=GRID||ny>=GRID) continue;
const nk = ny*GRID+nx;
if (parent.has(nk)) continue;
if (blocked.has(nk) && nk !== goalK) continue;
parent.set(nk, cur);
q.push({x:nx,y:ny});
}
}
return null;
}
```
Wait — parent stores the node object; and parent.get(k) returns the parent node. But I also set parent.set(startK, -1) as sentinel. Then reconstructing: while k !== -1 push node, then p = parent.get(k); if p === -1 break; node = p; k = p.y*GRID+p.x. Hmm, the first iteration pushes cur (goal). Then p = parent.get(goalK) = the parent node. Then node = p, k = key(p). Push p... loops until parent.get(k) === -1 (the start). That's correct.
Wait, but I do `k = p.y*GRID+p.x` before checking, and the loop condition... let me rewrite:
```js
let path = [];
let k = ck, node = cur;
while (true){
path.push(node);
const p = parent.get(k);
if (p === -1) break;
node = p; k = p.y*GRID+p.x;
}
return path.reverse();
```
Good.
Then the AI takes path[1] (the first step) as the new direction.
If BFS fails (no path), pick the safe move that maximizes flood-fill area.
```js
function safeMoves(){
const blocked = new Set();
for (let i=0;i<snake.length-1;i++) blocked.add(snake[i].y*GRID+snake[i].x);
const res = [];
for (const d of DIRS){
if (d.x === -dir.x && d.y === -dir.y) continue;
const nx = snake[0].x+d.x, ny = snake[0].y+d.y;
if (nx<0||ny<0||nx>=GRID||ny>=GRID) continue;
if (blocked.has(ny*GRID+nx)) continue;
res.push({d, x:nx, y:ny});
}
return res;
}
```
Flood fill from (nx,ny) counting reachable cells.
Then pick the max.
Also: if the BFS path's first step leads to a dead end (e.g., following the tail into a trap), it can be risky. Add a check: after computing the next step, simulate and check that there's still a path to the tail. Keep it simple — BFS-to-food with tail as free is decent. It might trap itself eventually. Since it's a demo, that's acceptable — showing game over in demo is bad though. Actually if the AI dies in demo, we can just restart the demo after a delay. Let's do that: if game over in demo mode, auto-restart the demo after ~1.2s. That's a nice touch. Actually the overlay shows game over... Hmm, in demo we don't show the game-over overlay. Let's: if state is 'demo' and it dies → just reset the game after a short delay, keep demo running.
Good.
Let's improve AI a bit: after BFS step, check that the resulting position can still reach the tail (i.e., not trapped). Add a "risk check": compute path to food; if the path length > available space, use flood-fill max. Meh, overkill. I'll do a simple safety: if the BFS next step leads to a state where the snake can reach its tail, take it; else fall back to max-area move. Let's implement cheaply:
Actually, simpler robust approach used often:
1. Compute BFS path to food. If it exists and the path length is "reasonable" (less than flood fill area), follow it.
2. Otherwise, follow the longest path to the tail (move that maximizes survival).
I'll implement: candidate = BFS first step. Then simulate the head move; check if the snake can still reach its tail via BFS (using the simulated body). If yes, take it. Else, choose the max-flood-fill safe move.
That's a good balance. Cost: BFS twice per step on a 22x22 grid = trivial.
Let's write a simulate function: given a body list and a move, return the new body (without food, no growth). Then BFS from new head to new tail.
OK, let's implement carefully but compactly.
```js
function bodyAfter(body, d){
// body: array of {x,y} head first
const nh = {x: body[0].x + d.x, y: body[0].y + d.y};
if (nh.x<0||nh.y<0||nh.x>=GRID||nh.y>=GRID) return null;
const nb = [nh, ...body.slice(0, body.length-1)]; // no growth
return nb;
}
```
Collision check with body: the new head can't land on any of nb.slice(0,-1)... Actually check against body.slice(0, body.length-1)? Hmm, after the move, the tail is removed. Check nh against body.slice(0, body.length-1)... hmm, but careful: body.slice(0,-1) excludes the tail. If nh is at the old tail position, that's fine.
Let me just do: `const obs = body.slice(0, body.length-1); if (obs.some(c=>c.x===nh.x&&c.y===nh.y)) return null;`
Then canReachTail(nb): BFS from nb[0] to nb[nb.length-1] with obstacles = nb.slice(1, nb.length-1)... roughly. It should exclude the target and the tail.
Hmm, but the tail moves too, so the reachability check is approximate anyway. It's a demo. Fine.
Actually simpler heuristic that works well: check the flood fill area from nh (with the new body as obstacles). If area >= snake.length, safe. That's the classic. Let's use that.
```js
function floodArea(start, obstacles){
// BFS count
}
```
If area < snake.length → risky. Take the best among candidates by area.
Actually the tail-reachability check is more accurate. Let's just do floodArea and compare with snake length. Simple.
I'll do:
```js
function aiChoose(){
const head = snake[0];
const blocked = new Set();
for (let i=0;i<snake.length-1;i++) blocked.add(snake[i].y*GRID+snake[i].x);
const path = bfs(head, food, blocked);
let candidates = safeMoves(); // array of {d, x, y}
if (!candidates.length) return null;
// prefer path step
if (path && path.length > 1){
const stepCell = path[1];
const d = {x: stepCell.x-head.x, y: stepCell.y-head.y};
const cand = candidates.find(c=>c.d.x===d.x&&c.d.y===d.y);
if (cand){
// check safety
const nb = bodyAfter(snake, d);
const area = floodArea(nb[0], nb);
if (area >= snake.length) return d;
}
}
// fallback: maximize area
let best = null, bestArea = -1;
for (const c of candidates){
const nb = bodyAfter(snake, c.d);
if (!nb) continue;
const area = floodArea(nb[0], nb);
if (area > bestArea){ bestArea = area; best = c.d; }
}
return best;
}
```
Note bodyAfter uses `body.slice(0, len-1)`, but snake may be length 1. Handle.
Note: the flood area should exclude the body cells. floodArea(start, body) where body includes the head... Let's make floodArea(start, obstacles) where obstacles is a Set of keys. Fine.
Complexity is fine.
Also the AI should avoid reversing: safeMoves filters that.
Now the DFS/BFS in the AI uses `snake` global. Fine.
One more: the demo AI might get stuck if `food` is null. Ensure food always exists.
Food spawning: collect all free cells, pick random. If none (win), leave null / game over win.
Now let's write the render.
```js
function render(t){
const W = size; // css px
ctx.setTransform(dpr,0,0,dpr,0,0);
ctx.clearRect(0,0,W,W);
// board
...
}
```
Where `size` = canvas CSS width, cell = size/GRID.
Board background: rounded rect clip.
```js
ctx.save();
roundRect(ctx, 0,0,W,W, 18); ctx.clip();
ctx.fillStyle = '#08130f'; ctx.fillRect(0,0,W,W);
// checker
for (let y=0;y<GRID;y++) for(let x=0;x<GRID;x++){
if ((x+y)%2===0){ ctx.fillStyle='rgba(255,255,255,0.018)'; ctx.fillRect(x*cell,y*cell,cell,cell); }
}
// subtle radial glow
```
Then the snake, food, particles, all inside the clip.
Then border stroke: rounded rect with a gradient stroke.
Let me write the interpolated snake points:
```js
const pts = [];
for (let i=0;i<snake.length;i++){
const c = snake[i];
const p = prev[i] !== undefined ? prev[i] : prev[prev.length-1] || c;
const x = (p.x + (c.x - p.x)*t + 0.5) * cell;
const y = (p.y + (c.y - p.y)*t + 0.5) * cell;
pts.push({x,y});
}
```
Wait, `prev` is the snake before the last step. But careful: if the snake grew, prev is shorter. Handled.
Hmm, potential issue: if prev has length 0 (first frame), handle.
Also, when the snake is initialized, prev = snake.slice().
Edge: `prev[prev.length-1]` when prev is empty → undefined. Guard.
Now stroke:
```js
const lw = cell * 0.78;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
for (let i = pts.length-1; i >= 0; i--){
const a = pts[i], b = pts[i+1] || pts[i];
const u = pts.length>1 ? i/(pts.length-1) : 0;
ctx.strokeStyle = `hsl(${142+34*u} ${86-18*u}% ${63-24*u}%)`;
ctx.lineWidth = lw * (1 - 0.15*u); // taper toward tail
ctx.beginPath(); ctx.moveTo(a.x,a.y); ctx.lineTo(b.x,b.y); ctx.stroke();
}
```
Hmm, the last point i = pts.length-1 → b = a, so lineTo same point → with round cap draws a dot. Good.
But wait: drawing from tail to head means the segment i is drawn from a=pts[i] to b=pts[i+1]. The color is that of index i. So the tail-most segment uses the tail's color. Good.
Taper: decreasing line width toward the tail makes it look nicer. With round caps and varying widths, joins are still OK.
Hmm, but each segment stroke with round caps has a width based on its own index. The overlap between segment i (width w_i) and i+1 (w_{i+1}) — fine.
Also add a glow: before drawing, set ctx.shadowColor = 'rgba(90,255,170,0.5)'; shadowBlur = 18; only on the head maybe. Let's add a soft glow to the whole snake by drawing once with a shadow, then... that doubles cost. Just do a head glow.
Head: drawn at pts[0], a circle radius lw*0.52... Actually the head cap is already round from the stroke. Draw a slightly larger circle with a lighter fill and a glow.
Eyes based on dir.
Let me now write the food:
```js
const fx = (food.x+0.5)*cell, fy = (food.y+0.5)*cell;
const pulse = 1 + Math.sin(now/220)*0.08;
ctx.save();
ctx.shadowColor = 'rgba(255,80,110,0.9)'; ctx.shadowBlur = cell*0.9;
const g = ctx.createRadialGradient(fx-cell*0.1, fy-cell*0.12, cell*0.05, fx, fy, cell*0.36*pulse);
g.addColorStop(0,'#ffd9a0'); g.addColorStop(0.45,'#ff6b6b'); g.addColorStop(1,'#e02a4d');
ctx.fillStyle = g;
ctx.beginPath(); ctx.arc(fx, fy, cell*0.33*pulse, 0, Math.PI*2); ctx.fill();
ctx.restore();
```
Plus maybe a small highlight.
Particles:
```js
for each: ctx.globalAlpha = life/max; ctx.fillStyle = color; ctx.beginPath(); ctx.arc(x,y,size,0,PI*2); ctx.fill();
```
Use additive blending? `ctx.globalCompositeOperation='lighter'` for particles.
Now the overlay DOM handling.
```html
<div id="overlay" class="overlay">
<div class="card">
<h1 id="ovTitle">SNAKE</h1>
<p id="ovText">Click or press any key to start</p>
<div id="ovScore" class="ov-score"></div>
</div>
</div>
```
Hmm, for the start screen, the game is playing behind the overlay. If the overlay covers the board with a dark tint, the demo is less visible. Let's make the overlay a small pill at the bottom-center of the board for the demo state, and a full dark overlay for game over.
Actually, let's do:
- demo state: a pill at the bottom center: "Click or press any key to start" with pulsing animation, semi-transparent dark bg with blur. Doesn't block the view.
- game over: full overlay with dark tint, score, restart hint.
- paused: full overlay with dark tint, "PAUSED".
I'll use one overlay element with a class controlling the style. Or two elements: `#hint` (bottom pill) and `#overlay` (full). Let's use two.
Let me write it.
```html
<div class="stage">
<canvas id="game"></canvas>
<div class="hint" id="hint">Click or press any key to start</div>
<div class="overlay" id="overlay">
<div class="ov-card">
<div class="ov-title" id="ovTitle">GAME OVER</div>
<div class="ov-score" id="ovScore">0</div>
<div class="ov-sub" id="ovSub">Press Space or Enter to restart</div>
</div>
</div>
</div>
```
CSS: .overlay { position:absolute; inset:0; display:flex; align-items:center; justify-content:center; background: rgba(4,12,9,0.72); backdrop-filter: blur(6px); border-radius: 18px; opacity:0; pointer-events:none; transition: opacity .25s; } .overlay.show{opacity:1;}
.hint { position:absolute; left:50%; bottom: 8%; transform: translateX(-50%); ... } with a pulse animation.
Now events:
```js
window.addEventListener('keydown', e => {
const k = e.key;
if (['ArrowUp','ArrowDown','ArrowLeft','ArrowRight',' ','Spacebar'].includes(k)) e.preventDefault();
if (state === 'demo'){ startGame(); return; } // hmm, should any key start?
...
});
```
Careful: in demo mode, pressing a key starts the game. Let's make any key or click start it.
```js
function startGame(){
state = 'playing';
hint.classList.remove('show');
overlay.classList.remove('show');
reset();
}
```
But reset() sets score=0, snake at start, etc.
Key handling in playing:
- P: toggle pause (if state === 'playing' → 'paused'; if 'paused' → 'playing').
- Space/Enter: if 'over' → restart.
For direction: arrows/WASD → queueDir. Also allow starting.
But careful: if the player presses space during play, nothing? Let's have space also pause? No — requirement says P pauses. Space restarts at game over. Let's leave space inert during play (or pause). I'll leave inert.
Hmm, but when the state is 'over' and you press space → restart. Good.
Edge: `state === 'paused'` and press Space → resume? Let's allow P or Space/Enter to resume. Actually to be safe: P toggles; Space/Enter resumes from pause too. Hmm, but then Space during pause would also... fine.
Let's write:
```js
function onKey(e){
const k = e.key;
const lower = k.length === 1 ? k.toLowerCase() : k;
if (['ArrowUp','ArrowDown','ArrowLeft','ArrowRight',' ','Enter'].includes(k)) e.preventDefault();
if (state === 'demo'){ startGame(); return; }
if (lower === 'p'){ togglePause(); return; }
if (state === 'over'){
if (k === ' ' || k === 'Enter') startGame();
return;
}
if (state === 'paused'){
if (k === ' ' || k === 'Enter') togglePause();
return;
}
// playing
const map = {ArrowUp:[0,-1], ArrowDown:[0,1], ArrowLeft:[-1,0], ArrowRight:[1,0], w:[0,-1], s:[0,1], a:[-1,0], d:[1,0]};
const m = map[k] || map[lower];
if (m) queueDir({x:m[0], y:m[1]});
}
```
Careful: `map[k]` where k='W' → undefined, then map[lower]={'w'} works. Good.
Also, arrow keys during 'over' should maybe restart? No.
Click handler on the stage: if demo → startGame; if over → startGame; if paused → resume.
Now let's handle the demo auto-restart on death:
```js
function gameOver(){
state = 'over';
...
if (wasDemo) { setTimeout(()=> { if (state==='demo' || ...) }, ...) }
}
```
Hmm, state is set to 'over'. Let's track a flag `demoMode` that stays true until the player starts. Actually let's use a separate variable `isDemo` boolean. state ∈ playing/paused/over/demo? Let's keep `state` as 'demo'|'playing'|'paused'|'over' and when the demo dies, set state='over' but remember demoMode=true, then after 1.2s reset and go back to 'demo'.
Simpler:
```js
function gameOver(){
if (state === 'demo'){
// restart demo shortly
demoRespawnAt = performance.now() + 1200;
state = 'over';
return;
}
state = 'over';
showOverlay(...);
}
```
And in the loop, if state === 'over' && demoRespawnAt && now > demoRespawnAt → reset(true) with state='demo'.
Let me just add a flag.
Let's restructure the game object:
```js
let state = 'demo'; // 'demo' | 'playing' | 'paused' | 'over'
let demoRestartTimer = 0;
```
In reset():
```js
function reset(toDemo){
snake = [{x:8,y:11},{x:7,y:11},{x:6,y:11}];
prev = snake.slice();
dir = {x:1,y:0};
queue = [];
score = 0;
particles = [];
acc = 0;
spawnFood();
updateHUD();
}
```
For the demo, maybe start with a longer snake? Not needed.
Actually, for the demo, let's start with a decent length (5) so it's visible? Nah, it grows.
Hmm, actually for the first screenshot at 3 seconds, the demo will have eaten maybe 3-4 foods. That's fine and visible.
Let's set the initial snake length to 3.
Best score: update when score > best during play. But the requirement says "the best score of the session". Only track for real play. Let's track best across all games (including demo? no). I'll update best only in 'playing' state.
Hmm, but demo score displays too. The HUD score would show the demo score, which is weird. Let's just show it — it's fine, and it demonstrates the score. Actually for the start screen, maybe show "0". Hmm. Let's show the demo score; the pill says "click to play". It's fine and shows the game is alive.
Hmm, actually, when the player starts, the score resets to 0. That's fine.
Now the render loop and timing. Let's write:
```js
let lastTime = performance.now();
let acc = 0;
const BASE_STEP = 118;
function loop(now){
const dt = Math.min(now - lastTime, 80);
lastTime = now;
const stepMs = currentStepMs();
if (state === 'playing' || state === 'demo'){
acc += dt;
let guard = 0;
while (acc >= stepMs && guard++ < 4){
acc -= stepMs;
step();
if (state !== 'playing' && state !== 'demo') { acc = 0; break; }
}
} else if (state === 'over'){
acc = 0;
if (demoRestart) { ... }
}
updateParticles(dt/1000);
draw(now, state==='paused' ? 1 : Math.min(acc / stepMs, 1));
requestAnimationFrame(loop);
}
```
Hmm, if paused, acc stays at whatever it was. Fine.
For the demo restart timer, check `if (demoRestart && now > demoRestart)` — but demoRestart only set when in demo and died.
Wait, I set demoRestart in gameOver when state was 'demo'. But then state becomes 'over'. So in loop, state==='over' && demoRestart > 0 → wait for the timer then reset to demo.
OK.
For the initial paint: the game starts in demo mode immediately, so at 3s we see the snake moving with the hint pill.
Now the canvas resize:
```js
function resize(){
const rect = canvas.getBoundingClientRect();
dpr = Math.min(window.devicePixelRatio || 1, 2);
const w = Math.round(rect.width);
if (w === 0) return;
canvas.width = Math.round(w*dpr);
canvas.height = Math.round(w*dpr);
size = w;
cell = size / GRID;
}
```
Note the canvas CSS: `width:100%; height:100%; display:block;` inside the stage with aspect-ratio 1.
Use ResizeObserver on the stage.
Since we draw with `ctx.setTransform(dpr,0,0,dpr,0,0)` each frame, that's handled.
Now the draw function needs `size` and `cell` — globals.
Let's write the rounded rect helper (or use ctx.roundRect — supported in Chrome 99+). I'll use ctx.roundRect if available, else fallback. Just use ctx.roundRect — latest Chrome supports it. Fine, but let's add a tiny polyfill for safety.
Now let's write the full code.
```js
const canvas = document.getElementById('game');
const ctx = canvas.getContext('2d');
const GRID = 22;
let cell = 24, size = 528, dpr = 1;
const DIRS = [{x:0,y:-1},{x:0,y:1},{x:-1,y:0},{x:1,y:0}];
```
Wait, the snake initial dir is right, and there are walls. Start at (5,11) heading right.
Game state variables.
Let me write the whole thing now.
```js
// ---------- State ----------
let snake, prevSnake, dir, queue, food, score, best, particles, state, acc, demoRestartAt;
```
init:
```js
function reset(toDemo){
snake = [{x:4,y:11},{x:3,y:11},{x:2,y:11}];
prevSnake = snake.map(s=>({...s}));
dir = {x:1,y:0};
queue = [];
score = 0;
particles = [];
acc = 0;
food = null;
spawnFood();
updateHUD();
}
```
`acc` is a module-level var used in the loop; reset it here. But reset() is called from within the loop? No, from key handlers. Actually from the demo restart at the top of frame. OK.
Careful with the loop's `acc` and reset() setting acc = 0 — fine.
spawnFood:
```js
function spawnFood(){
const occ = new Set(snake.map(s=>s.y*GRID+s.x));
const free = [];
for (let y=0;y<GRID;y++) for (let x=0;x<GRID;x++) if (!occ.has(y*GRID+x)) free.push({x,y});
food = free.length ? free[(Math.random()*free.length)|0] : null;
}
```
Also avoid spawning right in front of the head? Meh.
step():
```js
function step(){
if (state === 'demo'){
const d = aiChoose();
if (d) dir = d;
} else {
while (queue.length){
const nd = queue.shift();
if (nd.x === -dir.x && nd.y === -dir.y) continue;
if (nd.x === dir.x && nd.y === dir.y) continue;
dir = nd;
break;
}
}
const head = snake[0];
const nx = head.x + dir.x, ny = head.y + dir.y;
// wall
if (nx < 0 || ny < 0 || nx >= GRID || ny >= GRID) return die();
// self (exclude tail if not growing)
const willEat = food && nx === food.x && ny === food.y;
const limit = willEat ? snake.length : snake.length - 1;
for (let i = 0; i < limit; i++) if (snake[i].x === nx && snake[i].y === ny) return die();
prevSnake = snake.map(s=>({...s}));
snake.unshift({x:nx, y:ny});
if (willEat){
score += 10;
if (score > best) best = score;
burst((food.x+0.5)*cell, (food.y+0.5)*cell);
spawnFood();
updateHUD();
} else {
snake.pop();
}
}
```
Wait — `prevSnake` must be set before unshift. And in the not-eating case, snake.pop() after unshift gives the same length. prevSnake.length = old length = new length. Good.
Hmm, but for interpolation I need prevSnake to be the pre-step positions. Yes.
But careful: on the first step after reset, prevSnake = snake (same). Fine.
Also: when eating, `burst` uses cell which is the current cell size. Fine.
die():
```js
function die(){
if (state === 'demo'){
state = 'over';
demoRestartAt = performance.now() + 1400;
bumpShake();
return;
}
state = 'over';
showOverlay();
bumpShake();
}
```
Screen shake: nice touch. Add a shake variable decaying.
Let's add `shake` amount; in draw, translate by random offsets when shake > 0.
Now aiChoose. Let me write it with the helpers.
```js
const KEY = (x,y) => y*GRID + x;
function bfsPath(start, goal, blocked){
const sk = KEY(start.x,start.y);
const gk = KEY(goal.x,goal.y);
const parent = new Map([[sk, null]]);
const q = [start];
let h = 0;
while (h < q.length){
const cur = q[h++];
const ck = KEY(cur.x,cur.y);
if (ck === gk){
const path = [];
let node = cur, k = ck;
while (node){
path.push(node);
const p = parent.get(k);
node = p; k = p ? KEY(p.x,p.y) : -1;
}
return path.reverse();
}
for (const d of DIRS){
const nx = cur.x+d.x, ny = cur.y+d.y;
if (nx<0||ny<0||nx>=GRID||ny>=GRID) continue;
const nk = KEY(nx,ny);
if (parent.has(nk)) continue;
if (blocked.has(nk) && nk !== gk) continue;
parent.set(nk, cur);
q.push({x:nx,y:ny});
}
}
return null;
}
```
Note parent.set(sk, null) → `parent.get(k)` returns null for start → the while loop ends. But `parent.has(nk)` for start is true. Good.
Note: the start cell is the head, and blocked likely contains the head (since we block snake[0..len-2] which includes the head). That's fine because we skip checking blocked for the start (we start there).
Hmm, but the head is blocked and neighbors of the head — we check `blocked.has(nk)` for neighbors, which is right.
floodArea:
```js
function floodArea(start, blocked){
const seen = new Set([KEY(start.x,start.y)]);
const q = [start];
let h = 0, count = 0;
while (h < q.length){
const c = q[h++];
count++;
for (const d of DIRS){
const nx = c.x+d.x, ny = c.y+d.y;
if (nx<0||ny<0||nx>=GRID||ny>=GRID) continue;
const nk = KEY(nx,ny);
if (seen.has(nk) || blocked.has(nk)) continue;
seen.add(nk);
q.push({x:nx,y:ny});
}
}
return count;
}
```
blocked should be a Set of keys, and it should contain the body cells (the start is in it, so we add it to seen first).
bodySet(body, excludeTail) → Set.
AI:
```js
function aiChoose(){
const head = snake[0];
const bodySet = new Set();
for (let i=0;i<snake.length-1;i++) bodySet.add(KEY(snake[i].x, snake[i].y));
const candidates = [];
for (const d of DIRS){
if (d.x === -dir.x && d.y === -dir.y) continue;
const nx = head.x+d.x, ny = head.y+d.y;
if (nx<0||ny<0||nx>=GRID||ny>=GRID) continue;
if (bodySet.has(KEY(nx,ny))) continue;
candidates.push({d, x:nx, y:ny});
}
if (!candidates.length) return null;
// helper: simulate
function sim(d){
const nh = {x: head.x+d.x, y: head.y+d.y};
const nb = [nh, ...snake.slice(0, snake.length-1)];
return nb;
}
const pathToFood = food ? bfsPath(head, food, bodySet) : null;
if (pathToFood && pathToFood.length > 1){
const step = pathToFood[1];
const d = {x: step.x-head.x, y: step.y-head.y};
const cand = candidates.find(c => c.d.x===d.x && c.d.y===d.y);
if (cand){
const nb = sim(d);
const obs = new Set(nb.slice(0, nb.length-1).map(c=>KEY(c.x,c.y)));
if (floodArea(nb[0], obs) >= snake.length) return d;
}
}
// fallback: max area
let best = null, bestArea = -1;
for (const c of candidates){
const nb = sim(c.d);
const obs = new Set(nb.slice(0, nb.length-1).map(c2=>KEY(c2.x,c2.y)));
const a = floodArea(nb[0], obs);
if (a > bestArea){ bestArea = a; best = c.d; }
}
return best;
}
```
Hmm, `snake.length` at the point of the check — after eating, the snake is longer. But close enough.
Also floodArea's start is nb[0], which is in obs → we add it to seen first, so it's fine (we count it). Count starts at 1 for the start. Good.
Note the AI could also consider "chase the tail" when no path to food. The max-area fallback does that implicitly.
Edge case: if the food is unreachable, it wanders. Fine.
Performance: sim creates arrays each step; fine at ~8 steps/sec.
Now particles.
```js
function burst(px, py){
const n = 22;
for (let i=0;i<n;i++){
const a = Math.random()*Math.PI*2;
const sp = 60 + Math.random()*180;
particles.push({
x: px, y: py,
vx: Math.cos(a)*sp,
vy: Math.sin(a)*sp,
life: 0.5 + Math.random()*0.35,
max: 0,
r: 1.5 + Math.random()*2.5,
hue: 20 + Math.random()*40,
});
}
// set max
for (const p of particles) if (!p.max) p.max = p.life;
}
```
Hmm, better: set max = life at creation.
Also add some "spark" particles with the green hue.
Update:
```js
function updateParticles(dt){
for (let i=particles.length-1;i>=0;i--){
const p = particles[i];
p.life -= dt;
if (p.life <= 0){ particles.splice(i,1); continue; }
p.x += p.vx*dt; p.y += p.vy*dt;
p.vx *= 0.94; p.vy *= 0.94; // frame-rate dependent-ish, fine
p.vy += 120*dt; // slight gravity? maybe not
}
}
```
Use friction pow(0.0025, dt) style. Simple: `const f = Math.pow(0.12, dt);` hmm. Let's do `p.vx *= Math.pow(0.06, dt); p.vy *= Math.pow(0.06, dt);` — at dt=1/60, 0.06^0.0167 = e^(ln0.06*0.0167)= e^(-0.0469)=0.954. Good.
Draw with globalCompositeOperation 'lighter'.
Now the draw function.
```js
function draw(now, t){
const W = size;
ctx.setTransform(dpr,0,0,dpr,0,0);
ctx.clearRect(0,0,W,W);
ctx.save();
if (shake > 0.1){
ctx.translate((Math.random()-0.5)*shake, (Math.random()-0.5)*shake);
}
// board bg
roundRect(ctx, 0,0,W,W, 20);
ctx.save();
ctx.clip();
const bg = ctx.createRadialGradient(W*0.5, W*0.35, W*0.05, W*0.5, W*0.5, W*0.85);
bg.addColorStop(0,'#0e2a20');
bg.addColorStop(1,'#06120e');
ctx.fillStyle = bg; ctx.fillRect(0,0,W,W);
// checker
...
// food
// snake
// particles
ctx.restore();
// border
ctx.lineWidth = 2;
ctx.strokeStyle = 'rgba(120,255,190,0.18)';
roundRect(ctx,1,1,W-2,W-2,20); ctx.stroke();
ctx.restore();
}
```
Order: particles should be behind or in front of the snake? On top is fine (they're a burst).
Now, the cell rendering. Let's write.
```js
function drawSnake(t){
const pts = [];
const n = snake.length;
for (let i=0;i<n;i++){
const c = snake[i];
const p = (i < prevSnake.length) ? prevSnake[i] : prevSnake[prevSnake.length-1];
const px = p ? p.x : c.x;
const py = p ? p.y : c.y;
pts.push({
x: (px + (c.x - px)*t + 0.5) * cell,
y: (py + (c.y - py)*t + 0.5) * cell
});
}
...
}
```
Wait — careful with `prevSnake[i]` when i is beyond prevSnake's length. When growing, prevSnake.length = n-1, so for i = n-1, p = prevSnake[n-2], and c = snake[n-1] which equals prevSnake[n-2] (since the tail didn't move... let's verify).
After eating: snake = [newHead, ...oldSnake] (length n, no pop). prevSnake = oldSnake (length n-1).
- snake[i] = prevSnake[i-1] for i>=1.
- For i = n-1: snake[n-1] = prevSnake[n-2]. And p = prevSnake[n-2] (since i >= prevSnake.length). So p === c → the last segment stays. Correct.
Great.
Now, drawing the tail: if the snake is short (3 segments), it's fine.
Also, hmm: there's a subtle issue with `prevSnake` when the snake dies — we don't update. Fine.
Now, draw with strokes:
```js
const lw = cell * 0.8;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
// outer glow
ctx.save();
ctx.shadowColor = 'rgba(74,255,170,0.45)';
ctx.shadowBlur = cell*0.6;
ctx.strokeStyle = 'rgba(60,220,150,0.9)';
...
```
Hmm, shadow on every segment is expensive. Let's do a single glow pass: draw the whole polyline once with a slightly larger lineWidth and low alpha, then draw the segments.
Actually simpler: skip the glow for the body, just add a glow on the head.
Let me draw:
1. A dark "outline" stroke? Not needed.
Let's just draw the segments.
```js
for (let i = n-1; i >= 0; i--){
const a = pts[i];
const b = pts[i+1] || pts[i];
const u = n > 1 ? i/(n-1) : 0;
const hue = 145 + 30*u;
const sat = 88 - 22*u;
const lig = 62 - 22*u;
ctx.strokeStyle = `hsl(${hue} ${sat}% ${lig}%)`;
ctx.lineWidth = lw * (1 - 0.22*u);
ctx.beginPath();
ctx.moveTo(a.x, a.y);
ctx.lineTo(b.x, b.y);
ctx.stroke();
}
```
For n=1, i=0, a=b=pts[0] → a dot. OK.
Then the head:
```js
const h = pts[0];
const hx = h.x, hy = h.y;
// head circle
ctx.save();
ctx.shadowColor = 'rgba(140,255,190,0.9)';
ctx.shadowBlur = cell*0.8;
ctx.fillStyle = `hsl(145 90% 68%)`;
ctx.beginPath(); ctx.arc(hx, hy, lw*0.56, 0, Math.PI*2); ctx.fill();
ctx.restore();
```
Eyes:
```js
const dx = dir.x, dy = dir.y;
const perpX = -dy, perpY = dx;
const eyeOff = cell*0.17;
const fwd = cell*0.16;
for (const s of [1,-1]){
const ex = hx + dx*fwd + perpX*eyeOff*s;
const ey = hy + dy*fwd + perpY*eyeOff*s;
ctx.fillStyle = '#04221a';
ctx.beginPath(); ctx.arc(ex, ey, cell*0.115, 0, Math.PI*2); ctx.fill();
// pupil highlight
ctx.fillStyle = '#eafff5';
ctx.beginPath(); ctx.arc(ex + dx*cell*0.03 - 0, ey + dy*cell*0.03, cell*0.05, 0, Math.PI*2); ctx.fill();
}
```
Hmm, dark eye with a white dot inside. Let's do: white sclera circle, dark pupil. Or dark eye with white glint — looks cuter. Let's do: dark eye (near-black green) radius 0.12*cell, then a white glint radius 0.045*cell offset up-left.
Actually a white sclera with a dark pupil reads better at small sizes. Let's do sclera white r=0.115, pupil dark r=0.06 offset toward the direction by 0.04.
Hmm, direction is toward movement. Fine.
Now the food.
Also I should draw a subtle "aura" for the food.
Now the HUD update:
```js
function updateHUD(){
scoreEl.textContent = score;
bestEl.textContent = best;
}
```
Maybe animate the score pop. Add a CSS class toggle.
Let's add a `pop` animation on the score when it changes.
Now, the initial `best` = 0.
Let me also handle the case where the game is won (snake fills the board) — if food is null, the game continues with no food. Edge case, ignore.
Let's write the CSS now.
```css
*{box-sizing:border-box}
html,body{height:100%}
body{
margin:0;
background:
radial-gradient(1200px 700px at 50% -10%, #123a2c 0%, #071410 45%, #040b09 100%);
color:#dff5ea;
font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
display:flex; align-items:center; justify-content:center;
overflow:hidden;
-webkit-font-smoothing:antialiased;
user-select:none;
}
```
Also add a subtle animated glow? Skip.
```css
.app{
display:flex; flex-direction:column; align-items:center;
gap: 14px;
padding: 16px;
width: 100%;
max-width: 720px;
}
```
Hmm, with body flex centering and .app max-width 720px — on a 1280 viewport, the app is 720 wide. Board width = min(70vh, 100%) → 70vh = 560px on an 800 height. Total height = 560 + header(44) + footer(20) + gaps(28) + padding(32) = 684. Good.
Actually let's use `min(72vh, 100%)` → 576. Total ~700. Fine.
Header:
```css
.hud{ display:flex; align-items:center; justify-content:space-between; width:100%; }
.brand{ font-weight:800; letter-spacing:.32em; font-size:14px; color:#7cf5b8; text-shadow:0 0 18px rgba(90,255,170,.55); }
.stats{ display:flex; gap:10px; }
.stat{ background: rgba(255,255,255,.05); border:1px solid rgba(120,255,190,.18); border-radius:12px; padding:6px 12px; min-width:78px; text-align:center; }
.stat .lbl{ display:block; font-size:9px; letter-spacing:.18em; color:#6fa192; }
.stat .val{ font-size:18px; font-weight:700; font-variant-numeric: tabular-nums; }
```
Stage:
```css
.stage{
position:relative;
width: min(72vh, 100%);
aspect-ratio: 1/1;
border-radius: 20px;
box-shadow: 0 24px 60px -20px rgba(0,0,0,.9), 0 0 0 1px rgba(120,255,190,.12);
}
canvas{ display:block; width:100%; height:100%; border-radius:20px; }
```
Hmm — `.stage` width min(72vh, 100%) — `100%` refers to the flex container's width. But .app is a flex column with align-items:center, so the stage's width would be... `width: min(72vh, 100%)` — 100% of .app's content width (720 - 32 = 688). On a 1280x800 screen: 72vh = 576. So width 576. Good.
On a 375x800 screen: 72vh=576, 100% = 343. min → 343. Good.
But `aspect-ratio` with width set → height = width. Good.
Hmm, but on a short screen (e.g., 375x600), 72vh = 432, and the width is 343, so the board is 343 tall + header + footer ≈ 500. OK.
Now, the hint pill:
```css
.hint{
position:absolute; left:50%; bottom:6%;
transform: translateX(-50%);
padding: 10px 18px;
border-radius: 999px;
background: rgba(6,20,15,.82);
border: 1px solid rgba(120,255,190,.28);
color:#bfffe0;
font-size: 13px; letter-spacing:.06em;
white-space: nowrap;
backdrop-filter: blur(6px);
pointer-events:none;
opacity:0; transition: opacity .3s;
animation: pulse 1.8s ease-in-out infinite;
box-shadow: 0 0 30px rgba(80,255,170,.18);
}
.hint.show{ opacity:1; }
@keyframes pulse{ 0%,100%{ transform:translateX(-50%) scale(1); } 50%{ transform:translateX(-50%) scale(1.045); } }
```
Careful: the animation sets transform which conflicts with the base transform — fine since the keyframes include translateX(-50%).
Overlay:
```css
.overlay{
position:absolute; inset:0; border-radius:20px;
display:flex; flex-direction:column; align-items:center; justify-content:center;
background: rgba(4,14,10,.78);
backdrop-filter: blur(8px);
opacity:0; pointer-events:none; transition: opacity .25s ease;
text-align:center;
}
.overlay.show{ opacity:1; pointer-events:auto; }
```
Card content:
```css
.ov-title{ font-size: 12px; letter-spacing:.4em; color:#79c9a8; margin-bottom: 6px; }
.ov-score{ font-size: 56px; font-weight: 800; color:#8affc4; text-shadow:0 0 30px rgba(80,255,170,.6); line-height:1; }
.ov-sub{ margin-top: 14px; font-size: 13px; color:#9ec9b8; }
.ov-sub b{ color:#c8ffe4 }
```
Maybe show "BEST" too in the overlay.
Now the "click to start" — clicking the hint should start. Add a click handler on the stage.
Let's finalize the code.
One more detail: the `resize` should be called before the first draw. Use ResizeObserver + initial call.
Also, since the canvas is inside .stage with width:100%/height:100%, and the stage has aspect-ratio, the canvas rect is square.
Potential issue: `getBoundingClientRect()` returns fractional sizes; round to integers. With rounding, cell = size/GRID might be fractional — fine.
Let me also handle the case where the layout hasn't happened at the time of the initial call — use requestAnimationFrame or ResizeObserver, which fires initially.
I'll call resize() in the loop if the size changed... Actually simplest: ResizeObserver on the stage, and also call resize() once at startup.
Let's write the final code now.
```js
const cfg = { GRID: 22 };
```
Let me write it linearly.
```js
(function(){
'use strict';
const canvas = document.getElementById('game');
const ctx = canvas.getContext('2d');
const scoreEl = document.getElementById('score');
const bestEl = document.getElementById('best');
const hintEl = document.getElementById('hint');
const overlayEl = document.getElementById('overlay');
const ovTitle = document.getElementById('ovTitle');
const ovScore = document.getElementById('ovScore');
const ovSub = document.getElementById('ovSub');
const GRID = 22;
let cell = 24, size = 528, dpr = 1;
const DIRS = [{x:0,y:-1},{x:1,y:0},{x:0,y:1},{x:-1,y:0}];
let snake, prevSnake, dir, queue, food, score, best = 0, particles, state, acc = 0, shake = 0, demoRespawnAt = 0;
let lastTime = performance.now();
...
```
Note `let state` then `state = 'demo'`.
Init:
```js
state = 'demo';
reset();
hintEl.classList.add('show');
requestAnimationFrame(loop);
```
Careful: `reset()` calls `updateHUD()` and uses `best` which is defined.
Now the loop:
```js
function loop(now){
requestAnimationFrame(loop);
const dt = Math.min(now - lastTime, 100);
lastTime = now;
const stepMs = STEP_MS();
if (state === 'playing' || state === 'demo'){
acc += dt;
let guard = 0;
while (acc >= stepMs && guard++ < 5){
acc -= stepMs;
step();
if (state !== 'playing' && state !== 'demo'){ acc = 0; break; }
}
// if we broke out of the loop due to death, acc reset
} else if (state === 'over'){
acc = 0;
}
if (state === 'over' && demoRespawnAt && now >= demoRespawnAt){
demoRespawnAt = 0;
state = 'demo';
reset();
hintEl.classList.add('show');
}
if (shake > 0) shake = Math.max(0, shake - dt*0.02);
updateParticles(dt/1000);
draw(now, (state === 'playing' || state === 'demo') ? Math.min(acc/stepMs, 1) : 1);
}
```
Hmm, when paused, t should be frozen at the current acc/stepMs, not 1. Let's store `t` computed each frame:
```js
let t = 0;
if (state === 'playing' || state === 'demo') t = Math.min(acc/stepMs, 1);
```
And for paused, keep t at its previous value. So make `t` a module-level variable only updated when not paused.
Let me do:
```js
let t = 0;
// in loop:
if (state === 'playing' || state === 'demo') t = Math.min(acc/stepMs, 1);
else if (state === 'over') t = 1;
// paused: keep t
```
Good.
Also when state is 'over', the snake should render at t=1 (the final cell). Hmm, but at death, the head never entered the wall — the snake dies before moving. So the snake renders at its current positions. With t=1, the head is drawn at the last cell. Fine.
Actually wait: when we die, we don't update prevSnake/snake, so the render position is whatever the previous state was with t=1 → the head is at the last valid cell. Good.
Hmm, but t is computed as acc/stepMs and acc is set to 0 on death. With t=1 forced, that's the completed last step. Good.
Now `STEP_MS()`:
```js
function STEP_MS(){ return Math.max(72, 120 - Math.floor(score/50)*8); }
```
At score 300 → 120-48=72. OK.
For the demo, maybe use a constant 100 for snappier play. Let's use score-based for both; demo score grows too.
Hmm, demo until death could reach high scores and get very fast. Cap at 72ms (13.8 cells/sec). Fine.
Now write `draw`.
Let me write roundRect helper:
```js
function rrect(c, x, y, w, h, r){
if (c.roundRect){ c.beginPath(); c.roundRect(x,y,w,h,r); return; }
c.beginPath();
c.moveTo(x+r,y);
c.arcTo(x+w,y,x+w,y+h,r);
c.arcTo(x+w,y+h,x,y+h,r);
c.arcTo(x,y+h,x,y,r);
c.arcTo(x,y,x+w,y,r);
c.closePath();
}
```
Now the full draw:
```js
function draw(now, t){
const W = size;
ctx.setTransform(dpr,0,0,dpr,0,0);
ctx.clearRect(0,0,W,W);
ctx.save();
if (shake > 0.5){
ctx.translate((Math.random()-0.5)*shake, (Math.random()-0.5)*shake);
}
rrect(ctx, 0, 0, W, W, 20);
ctx.save();
ctx.clip();
// bg
const g = ctx.createRadialGradient(W*0.5, W*0.3, W*0.05, W*0.5, W*0.55, W*0.9);
g.addColorStop(0, '#102e23');
g.addColorStop(1, '#06120e');
ctx.fillStyle = g;
ctx.fillRect(0,0,W,W);
// checker
ctx.fillStyle = 'rgba(255,255,255,0.018)';
for (let y=0;y<GRID;y++){
for (let x=0;x<GRID;x++){
if ((x+y)&1) continue;
ctx.fillRect(x*cell, y*cell, cell, cell);
}
}
// hmm invert: (x+y)&1 means odd → skip. Fine.
// grid lines? subtle
ctx.strokeStyle = 'rgba(120,255,190,0.035)';
ctx.lineWidth = 1;
for (let i=1;i<GRID;i++){
ctx.beginPath(); ctx.moveTo(i*cell, 0); ctx.lineTo(i*cell, W); ctx.stroke();
ctx.beginPath(); ctx.moveTo(0, i*cell); ctx.lineTo(W, i*cell); ctx.stroke();
}
...
```
Grid lines at 22x22 = 44 lines — fine.
Hmm, the checker + grid lines might be busy. Let's use just the checker (subtle) — no grid lines. Actually the grid lines help show the cell structure. I'll keep very subtle lines.
Then food, snake, particles.
Then restore, draw the border.
Let me write the border:
```js
ctx.restore(); // clip
rrect(ctx, 0.75, 0.75, W-1.5, W-1.5, 20);
ctx.strokeStyle = 'rgba(120,255,190,0.16)';
ctx.lineWidth = 1.5;
ctx.stroke();
ctx.restore();
```
Now the full code. Let me write it out.
Additional: `document.addEventListener('keydown')` for keys — the iframe needs focus. Add a click handler on the document to focus. The requirement says the iframe receives keyboard events only after a click. We show a hint. Also add `window.focus()` maybe.
Now, in demo mode pressing any key starts. That handles it.
Let's also make sure that clicking anywhere in the stage starts the game.
I'll write:
```js
function startPlaying(){
if (state === 'playing' || state === 'paused') return;
state = 'playing';
demoRespawnAt = 0;
hintEl.classList.remove('show');
overlayEl.classList.remove('show');
reset();
}
```
And the click handler:
```js
document.addEventListener('pointerdown', (e) => {
if (state === 'demo' || state === 'over') startPlaying();
else if (state === 'paused') togglePause();
});
```
Hmm, but clicking during 'playing' does nothing. Fine.
But careful: after death in the demo, state='over' with demoRespawnAt set. If the user clicks during that window, startPlaying() runs → good.
Now, the game over overlay in the demo shouldn't show. Right — die() doesn't call showOverlay in demo mode.
showOverlay:
```js
function showOverlay(){
ovTitle.textContent = 'GAME OVER';
ovScore.textContent = score;
ovSub.innerHTML = 'Press <b>Space</b> or <b>Enter</b> to restart';
overlayEl.classList.add('show');
}
```
And for pause, show a different overlay. Let's add a `showPauseOverlay()`:
```js
ovTitle.textContent = 'PAUSED';
ovScore.textContent = '';
ovSub.innerHTML = 'Press <b>P</b> to resume';
```
Hmm, ovScore empty leaves a gap. Use a class or set display none. Let me add a separate element structure: the overlay card contains a title, a big score (which can be hidden), and a subtitle.
I'll toggle a `hidden` class on the score element.
Now let's write everything out properly.
Also, the hint should be hidden during game over for the demo... it's fine.
One more consideration: `hintEl.classList.add('show')` when the demo restarts. And when the player starts, remove.
Let me also add: on the first user interaction, `document.body.focus()`? Not needed.
Let's write it.
```html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Snake — Neon</title>
<style>...</style>
</head>
<body>
<div class="app">
<div class="hud">
<div class="brand">SNAKE</div>
<div class="stats">
<div class="stat"><span class="lbl">SCORE</span><span class="val" id="score">0</span></div>
<div class="stat"><span class="lbl">BEST</span><span class="val" id="best">0</span></div>
</div>
</div>
<div class="stage" id="stage">
<canvas id="game"></canvas>
<div class="hint show" id="hint">Click or press any key to start</div>
<div class="overlay" id="overlay">
<div class="ov-title" id="ovTitle">GAME OVER</div>
<div class="ov-score" id="ovScore">0</div>
<div class="ov-sub" id="ovSub">Press Space to restart</div>
</div>
</div>
<div class="foot">Arrows / WASD to move · P to pause · Space to restart</div>
</div>
<script>...</script>
</body>
</html>
```
Now the script. Let me write it completely and carefully.
```js
(function(){
'use strict';
const canvas = document.getElementById('game');
const ctx = canvas.getContext('2d');
const stageEl = document.getElementById('stage');
const scoreEl = document.getElementById('score');
const bestEl = document.getElementById('best');
const hintEl = document.getElementById('hint');
const overlayEl = document.getElementById('overlay');
const ovTitle = document.getElementById('ovTitle');
const ovScore = document.getElementById('ovScore');
const ovSub = document.getElementById('ovSub');
const GRID = 22;
const DIRS = [{x:0,y:-1},{x:1,y:0},{x:0,y:1},{x:-1,y:0}];
let cell = 24, size = 528, dpr = 1;
let snake = [], prevSnake = [], dir = {x:1,y:0}, queue = [];
let food = null, score = 0, best = 0;
let particles = [];
let state = 'demo';
let acc = 0, t = 0, shake = 0, demoRespawnAt = 0;
let lastTime = performance.now();
/* ---------- utils ---------- */
const KEY = (x,y) => y*GRID + x;
function rrect(c,x,y,w,h,r){
if (c.roundRect){ c.beginPath(); c.roundRect(x,y,w,h,r); return; }
c.beginPath();
c.moveTo(x+r,y);
c.arcTo(x+w,y,x+w,y+h,r);
c.arcTo(x+w,y+h,x,y+h,r);
c.arcTo(x,y+h,x,y,r);
c.arcTo(x,y,x+w,y,r);
c.closePath();
}
```
Then resize, reset, spawnFood, step, die, ai, draw, loop, events.
Let me write `resize`:
```js
function resize(){
const r = stageEl.getBoundingClientRect();
const w = Math.max(1, Math.round(r.width));
dpr = Math.min(window.devicePixelRatio || 1, 2);
if (canvas.width !== Math.round(w*dpr) || canvas.height !== Math.round(w*dpr)){
canvas.width = Math.round(w*dpr);
canvas.height = Math.round(w*dpr);
}
size = w;
cell = size / GRID;
}
```
Since the stage is square, r.height ≈ r.width.
ResizeObserver → resize. Also call resize() at init.
Note: `size` must be > 0 for the initial draw.
OK writing everything now. Let me be careful with the `draw` function's use of `t` param and the module-level `t`.
I'll name the draw param `it` (interp t).
Final code:
```js
function draw(now, it){
const W = size;
ctx.setTransform(dpr,0,0,dpr,0,0);
ctx.clearRect(0,0,W,W);
ctx.save();
if (shake > 0.5){
ctx.translate((Math.random()-0.5)*shake, (Math.random()-0.5)*shake);
}
rrect(ctx,0,0,W,W,20);
ctx.save();
ctx.clip();
// background
const bg = ctx.createRadialGradient(W*0.5, W*0.28, W*0.04, W*0.5, W*0.55, W*0.92);
bg.addColorStop(0,'#0f2f23');
bg.addColorStop(1,'#05100d');
ctx.fillStyle = bg;
ctx.fillRect(0,0,W,W);
// checker
ctx.fillStyle = 'rgba(255,255,255,0.017)';
for (let y=0;y<GRID;y++){
for (let x=0;x<GRID;x++){
if ((x+y)&1) continue;
ctx.fillRect(x*cell, y*cell, cell, cell);
}
}
// grid lines
ctx.strokeStyle = 'rgba(130,255,200,0.03)';
ctx.lineWidth = 1;
ctx.beginPath();
for (let i=1;i<GRID;i++){
ctx.moveTo(i*cell,0); ctx.lineTo(i*cell,W);
ctx.moveTo(0,i*cell); ctx.lineTo(W,i*cell);
}
ctx.stroke();
```
Hmm, `ctx.beginPath` combined is fine.
Wait, but the grid lines with the checker might be too much. Keep alpha low.
Wait — the checker uses `(x+y)&1` — for x+y odd, skip. Fine.
Continue:
```js
// food
if (food) drawFood(now);
// snake
drawSnake(it);
// particles
drawParticles();
ctx.restore(); // clip
rrect(ctx, 0.75, 0.75, W-1.5, W-1.5, 20);
ctx.strokeStyle = 'rgba(140,255,200,0.16)';
ctx.lineWidth = 1.5;
ctx.stroke();
ctx.restore();
}
```
drawFood:
```js
function drawFood(now){
const fx = (food.x+0.5)*cell;
const fy = (food.y+0.5)*cell;
const p = 1 + Math.sin(now/230)*0.09;
const r = cell*0.31*p;
ctx.save();
// glow
const glow = ctx.createRadialGradient(fx, fy, 0, fx, fy, cell*1.3);
glow.addColorStop(0,'rgba(255,90,120,0.35)');
glow.addColorStop(1,'rgba(255,90,120,0)');
ctx.fillStyle = glow;
ctx.beginPath(); ctx.arc(fx,fy,cell*1.3,0,Math.PI*2); ctx.fill();
const g = ctx.createRadialGradient(fx-cell*0.1, fy-cell*0.12, cell*0.03, fx, fy, r*1.15);
g.addColorStop(0,'#ffe3a3');
g.addColorStop(0.45,'#ff6b6b');
g.addColorStop(1,'#d81e4a');
ctx.fillStyle = g;
ctx.beginPath(); ctx.arc(fx,fy,r,0,Math.PI*2); ctx.fill();
// highlight
ctx.fillStyle = 'rgba(255,255,255,0.65)';
ctx.beginPath(); ctx.arc(fx - r*0.32, fy - r*0.36, r*0.2, 0, Math.PI*2); ctx.fill();
ctx.restore();
}
```
drawParticles:
```js
function drawParticles(){
if (!particles.length) return;
ctx.save();
ctx.globalCompositeOperation = 'lighter';
for (const p of particles){
const a = Math.max(0, p.life/p.max);
ctx.globalAlpha = a;
ctx.fillStyle = `hsl(${p.hue} 100% ${60}%)`;
ctx.beginPath();
ctx.arc(p.x, p.y, p.r*a + 0.4, 0, Math.PI*2);
ctx.fill();
}
ctx.restore();
}
```
Careful: `p.hue` — hsl with a number and `%` — `hsl(30 100% 60%)` is valid CSS Color 4 syntax and works in Chrome. Fine.
Particles position: they're in canvas CSS pixels. Since the canvas is resized on window resize, the particle positions are stale but that's fine.
Now `updateParticles`:
```js
function updateParticles(dt){
for (let i=particles.length-1;i>=0;i--){
const p = particles[i];
p.life -= dt;
if (p.life <= 0){ particles.splice(i,1); continue; }
p.x += p.vx*dt;
p.y += p.vy*dt;
const f = Math.pow(0.05, dt);
p.vx *= f; p.vy *= f;
}
}
```
Hmm, friction 0.05^dt at dt=0.0167 → 0.05^0.0167 = e^(-2.996*0.0167)=e^(-0.05)=0.951. So ~5% per frame. Over 0.5s (30 frames) → 0.951^30 = 0.22. OK, particles slow down.
Now let's finalize the whole file.
One more: the score pop animation. I'll add a class toggle with a reflow trick:
```js
function updateHUD(){
scoreEl.textContent = score;
bestEl.textContent = best;
}
```
Add pop:
```js
scoreEl.classList.remove('pop');
void scoreEl.offsetWidth;
scoreEl.classList.add('pop');
```
Only when eating. I'll add it in the eat branch.
CSS:
```css
.stat .val.pop{ animation: pop .3s ease; }
@keyframes pop{ 0%{ transform:scale(1);} 40%{ transform:scale(1.28); color:#b6ffd8;} 100%{transform:scale(1);} }
```
`.val` needs `display:inline-block` for transform.
OK.
Let me write out the final file.
Also, must make sure `reset()` is called before the first draw; `state='demo'` initially.
One thing: in `reset()`, `acc = 0`. And the loop uses `acc` — but `reset()` is called from the loop for the demo respawn. Let's just set acc=0 inside reset.
Careful: `reset()` is also called from `startPlaying()`. Fine.
Another detail: the demo's first `step()` — `aiChoose()` uses `dir` for the reversal check. Fine.
Let me write `die()` with shake:
```js
function die(){
shake = 12;
if (state === 'demo'){
state = 'over';
demoRespawnAt = performance.now() + 1500;
return;
}
state = 'over';
showOverlay();
}
```
Hmm, but the demo dies and the hint is showing; the hint stays. When the demo respawns, hint stays. Fine.
But wait — during the demo death, state = 'over', and the game-over branch does NOT show the overlay. Good. But the player could press space → startPlaying. Good.
Now let's make sure the loop's demo respawn check happens even if state is 'over' — yes.
But careful: `demoRespawnAt` is set with performance.now(), and the loop's `now` is the rAF timestamp. Same time base. Good.
Now writing the final code.
Also, in the play loop, when `state === 'over'`, the particles still update. Good.
Let me now also double check `step()` when the head is at the food and the snake grows. Fine.
One more: the `prevSnake` copy. In `step`, `prevSnake = snake.map(s => ({x:s.x, y:s.y}));` — a new array of new objects. This runs every step (~8/sec) — fine.
Alright, writing it.
For the initial snake, put it at y=11 (middle of 22) starting at x=4.
Let me write the final code.
Actually one more consideration on `cell` being used inside `burst()` before resize... it's computed at eat time, and resize has run. Fine.
Let me write out.
Also I should double check `floodArea` and `bfsPath` performance: each step calls bfsPath (22*22=484 cells) plus up to 3 floodAreas (484 each). Trivial.
Writing final.
```js
function aiChoose(){
const head = snake[0];
const bodySet = new Set();
for (let i=0;i<snake.length-1;i++) bodySet.add(KEY(snake[i].x, snake[i].y));
const cands = [];
for (const d of DIRS){
if (d.x === -dir.x && d.y === -dir.y) continue;
const nx = head.x + d.x, ny = head.y + d.y;
if (nx < 0 || ny < 0 || nx >= GRID || ny >= GRID) continue;
if (bodySet.has(KEY(nx,ny))) continue;
cands.push(d);
}
if (!cands.length) return null;
function areaAfter(d){
const nh = {x: head.x + d.x, y: head.y + d.y};
const nb = [nh];
for (let i=0;i<snake.length-1;i++) nb.push(snake[i]);
const obs = new Set();
for (let i=0;i<nb.length-1;i++) obs.add(KEY(nb[i].x, nb[i].y));
return floodArea(nh, obs);
}
if (food){
const path = bfsPath(head, food, bodySet);
if (path && path.length > 1){
const s = path[1];
const d = {x: s.x - head.x, y: s.y - head.y};
const ok = cands.some(c => c.x === d.x && c.y === d.y);
if (ok && areaAfter(d) >= snake.length) return d;
}
}
let bestD = null, bestA = -1;
for (const d of cands){
const a = areaAfter(d);
if (a > bestA){ bestA = a; bestD = d; }
}
return bestD;
}
```
Wait, `nb` construction: `nb = [nh, ...snake.slice(0, snake.length-1)]` — this is the body after the move (without growth). My loop does that. Good.
But `cands.some` — cands are direction objects. Good.
Note: `areaAfter` returns the flood area which includes the head cell. So comparing to snake.length is reasonable.
Hmm, one problem: `areaAfter(d) >= snake.length` — for a long snake this is often false, causing the AI to always take the fallback. That's fine since the fallback maximizes the area.
Actually, the condition should be more lenient: the snake just needs enough room to not trap itself. Using `>= snake.length` is the standard "can fit my whole body" test. Good.
Alright. But there's a subtlety: the flood fill doesn't account for the tail moving away, so it under-estimates. Fine.
Now let me write the final HTML.
Also `showOverlay`:
```js
function showOverlay(){
ovTitle.textContent = 'GAME OVER';
ovScore.textContent = score;
ovScore.classList.remove('hidden');
ovSub.innerHTML = 'Best ' + best + ' · <b>Space</b> or <b>Enter</b> to restart';
overlayEl.classList.add('show');
}
```
Hmm, mixing. Let's keep it clean:
title: "GAME OVER"
score: the big number
sub: "Press Space or Enter to restart"
And add a separate "best" line? The HUD shows best. Fine.
For pause:
```js
function showPause(){
ovTitle.textContent = 'PAUSED';
ovScore.textContent = score;
ovSub.innerHTML = 'Press <b>P</b> to resume';
overlayEl.classList.add('show');
}
```
togglePause:
```js
function togglePause(){
if (state === 'playing'){ state = 'paused'; showPause(); }
else if (state === 'paused'){ state = 'playing'; overlayEl.classList.remove('show'); }
}
```
But P during demo/over → ignore.
OK. Writing the final answer now.
One more: the keydown handler must not prevent default for all keys, only the game keys.
Also `e.repeat` — allow repeat? For direction, repeats could flood the queue. I'll ignore `e.repeat` for direction keys.
Let me write.
Also the resize: `new ResizeObserver(resize).observe(stageEl);` plus `resize()` initially.
Also `window.addEventListener('resize', resize)`.
Alright. Final code.
Let me also double check the `t` interpolation when `state==='paused'` — t stays at the last value. But `t` is only updated inside the loop for playing/demo/over. On pause, it retains its value. Good.
Let me write it.
Wait — one problem with `acc` and the demo: if the tab is inactive, rAF stops, then on return dt is clamped to 100ms, so at most a couple of steps. Fine.
Also on the very first frame, `lastTime` is set at script load, and the first rAF fires ~16ms later. Fine.
Writing now for real.
One more detail: `drawSnake` needs `prevSnake.length` checked — if `snake.length > 0` but prevSnake is empty, guard.
Also the snake's head eye direction uses `dir` which is the direction of the last move — but during the demo, `dir` is set in step(). Good.
Final code below.
Regarding the head circle radius: lw = cell*0.8, head circle r = lw*0.56 = 0.448*cell. That's slightly bigger than the body radius (0.4*cell). Good.
Eyes at offset fwd = cell*0.17, perp = cell*0.17, radius 0.12*cell. Eye centers at 0.17*cell from center — that's within the head (0.448). Good.
Let me write.
Also add a subtle tongue? No.
Final: writing the HTML.