MiniMax M3.1 Flash
by MiniMaxReasoningClean
Running live in a sandboxed frame. Click inside it before using the keyboard.
How it went
- Total time
- 1 min 10 s
- Cost
- $0.011
- Output tokens
- 17,782
- Spent thinking
- 10,040 tokens
- Speed
- 254.2 tok/s
- Console errors
- 0
- First token after
- 537 ms
- First visible text after
- 51.5 s
- Prompt tokens
- 677
- Code size
- 22.5 KB
- Finish reason
- stop
- Served by
- Nebius
- Model id
minimax/minimax-m3.1-flash- Reasoning sent
{"effort":"high"}- 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 · 593 lines
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Neon Snake</title>
<style>
*{box-sizing:border-box}
html,body{height:100%}
body{
margin:0;
background:
radial-gradient(1100px 760px at 15% -12%, #17294a 0%, rgba(23,41,74,0) 62%),
radial-gradient(900px 700px at 112% 18%, #0f4034 0%, rgba(15,64,52,0) 58%),
#06090f;
color:#e9f1ff;
font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
display:flex; align-items:center; justify-content:center;
padding:14px; min-height:100vh; overflow-x:hidden;
}
.app{width:min(96vw, 660px); display:flex; flex-direction:column; gap:12px;}
.hud{display:flex; align-items:center; justify-content:space-between; gap:10px;}
.brand{display:flex; align-items:center; gap:10px;}
.logo{
width:36px;height:36px;border-radius:11px;display:grid;place-items:center;font-size:19px;
background:linear-gradient(145deg,#2ee87a,#12a45c);
box-shadow:0 8px 22px -8px rgba(46,232,122,.85), inset 0 1px 0 rgba(255,255,255,.45);
}
.brand h1{
margin:0;font-size:21px;letter-spacing:.22em;font-weight:800;
background:linear-gradient(90deg,#b8ff6b,#7ef2c4 60%,#9ad7ff);
-webkit-background-clip:text;background-clip:text;color:transparent;
}
.brand small{display:block;font-size:10px;letter-spacing:.24em;color:#7f93b5;margin-top:2px;font-weight:600}
.stats{display:flex; gap:8px;}
.stat{
background:linear-gradient(180deg, rgba(255,255,255,.075), rgba(255,255,255,.03));
border:1px solid rgba(255,255,255,.10); border-radius:13px; padding:6px 13px; min-width:74px;
text-align:center; backdrop-filter: blur(8px);
}
.stat .label{display:block;font-size:9px;letter-spacing:.2em;color:#8ba0c4;font-weight:700}
.stat .value{display:block;font-size:21px;font-weight:800;font-variant-numeric:tabular-nums;line-height:1.25}
.stat.score .value{color:#a9ff7d;text-shadow:0 0 14px rgba(122,255,110,.45)}
.stat.best .value{color:#ffd166;text-shadow:0 0 14px rgba(255,209,102,.4)}
.stat.len .value{color:#8ad7ff;text-shadow:0 0 14px rgba(138,215,255,.35)}
@keyframes pop{0%{transform:scale(1)}32%{transform:scale(1.28)}100%{transform:scale(1)}}
.value.pop{animation:pop .3s cubic-bezier(.2,.9,.3,1.4)}
.frame{
position:relative; border-radius:24px; padding:12px;
background:linear-gradient(180deg, rgba(255,255,255,.07), rgba(255,255,255,.02));
border:1px solid rgba(255,255,255,.11);
box-shadow:0 34px 70px -26px rgba(0,0,0,.9), inset 0 1px 0 rgba(255,255,255,.10);
}
canvas{display:block;width:100%;height:auto;border-radius:14px;background:#080d18;}
.banner{
position:absolute; top:20px; left:50%; transform:translateX(-50%);
display:flex; align-items:center; gap:9px; white-space:nowrap;
padding:8px 16px; border-radius:999px; font-size:12px; font-weight:700; letter-spacing:.05em;
color:#eaf3ff; background:rgba(10,16,28,.72); border:1px solid rgba(255,255,255,.16);
backdrop-filter: blur(8px); box-shadow:0 10px 26px -12px rgba(0,0,0,.9);
pointer-events:none; transition:opacity .35s ease, transform .35s ease;
}
.banner.hide{opacity:0; transform:translate(-50%,-12px);}
.dot{width:8px;height:8px;border-radius:50%;background:#4ade80;box-shadow:0 0 10px #4ade80;animation:blink 1.4s infinite}
@keyframes blink{0%,100%{opacity:1}50%{opacity:.25}}
.overlay{position:absolute; inset:12px; display:grid; place-items:center; pointer-events:none;}
.modal{
pointer-events:auto; text-align:center; padding:26px 30px; border-radius:20px; min-width:250px;
background:rgba(9,14,25,.82); border:1px solid rgba(255,255,255,.14);
backdrop-filter: blur(12px); box-shadow:0 26px 60px -20px rgba(0,0,0,.95);
animation:rise .28s cubic-bezier(.2,.9,.3,1.2);
}
@keyframes rise{from{opacity:0;transform:translateY(14px) scale(.96)}to{opacity:1;transform:none}}
.m-title{font-size:14px; letter-spacing:.28em; font-weight:800; color:#ff8fa3; margin-bottom:10px}
.m-title.paused{color:#8ad7ff}
.m-score{font-size:52px;font-weight:900;line-height:1;color:#a9ff7d;text-shadow:0 0 26px rgba(122,255,110,.5);font-variant-numeric:tabular-nums}
.m-score small{display:block;font-size:10px;letter-spacing:.26em;color:#8ba0c4;margin-top:6px;font-weight:700;text-shadow:none}
.m-best{margin-top:14px;font-size:12px;letter-spacing:.16em;color:#ffd166;font-weight:700}
.m-hint{margin-top:16px;font-size:11px;color:#93a7c9;letter-spacing:.04em;line-height:1.7}
.btn{
margin-top:16px; cursor:pointer; font:inherit; font-weight:800; font-size:13px; letter-spacing:.14em;
color:#04180d; background:linear-gradient(180deg,#b8ff6b,#3ddc84); border:none; border-radius:12px; padding:11px 22px;
box-shadow:0 12px 26px -12px rgba(61,220,132,.95), inset 0 1px 0 rgba(255,255,255,.55);
transition:transform .12s ease, filter .12s ease;
}
.btn:hover{transform:translateY(-1px);filter:brightness(1.07)}
.btn:active{transform:translateY(1px)}
kbd{
display:inline-block; padding:2px 7px; border-radius:6px; font:inherit; font-size:10px; font-weight:800;
background:rgba(255,255,255,.10); border:1px solid rgba(255,255,255,.18); border-bottom-width:2px; color:#dce8ff;
}
footer{display:flex; flex-wrap:wrap; gap:8px 18px; justify-content:center; font-size:11px; color:#8ba0c4; letter-spacing:.04em}
footer span{display:flex; align-items:center; gap:6px}
.hint-title{letter-spacing:.22em; font-weight:800; color:#6f85ab; font-size:10px; margin-bottom:2px}
@media (max-width:520px){
.brand h1{font-size:17px} .brand small{display:none}
.stat{min-width:58px;padding:5px 9px} .stat .value{font-size:17px}
.m-score{font-size:42px} .modal{padding:20px 22px; min-width:200px}
.frame{padding:8px;border-radius:18px} .overlay{inset:8px}
.banner{font-size:10.5px;padding:6px 12px}
}
</style>
</head>
<body>
<div class="app">
<header class="hud">
<div class="brand">
<div class="logo">🐍</div>
<div>
<h1>SNAKE</h1>
<small>ARCADE EDITION</small>
</div>
</div>
<div class="stats">
<div class="stat score"><span class="label">SCORE</span><span class="value" id="score">0</span></div>
<div class="stat best"><span class="label">BEST</span><span class="value" id="best">0</span></div>
<div class="stat len"><span class="label">LENGTH</span><span class="value" id="len">3</span></div>
</div>
</header>
<div class="frame">
<canvas id="cv" width="640" height="640" aria-label="Snake game board"></canvas>
<div class="banner" id="banner"><span class="dot"></span><span id="bannerText">Click or press a key to start</span></div>
<div class="overlay" id="overlay" style="display:none">
<div class="modal">
<div class="m-title" id="mTitle">GAME OVER</div>
<div class="m-score"><span id="mScore">0</span><small>SCORE</small></div>
<div class="m-best">BEST <span id="mBest">0</span></div>
<button class="btn" id="btn">PLAY AGAIN</button>
<div class="m-hint"><kbd>Space</kbd> / <kbd>Enter</kbd> to restart · <kbd>P</kbd> to pause</div>
</div>
</div>
</div>
<footer>
<div class="hint-title">CONTROLS</div>
<span><kbd>↑</kbd><kbd>↓</kbd><kbd>←</kbd><kbd>→</kbd> or <kbd>W</kbd><kbd>A</kbd><kbd>S</kbd><kbd>D</kbd> move</span>
<span><kbd>Space</kbd> / <kbd>Enter</kbd> restart</span>
<span><kbd>P</kbd> pause</span>
</footer>
</div>
<script>
(() => {
"use strict";
const GRID = 20; // 20 x 20 cells
const DIRS = [[1,0],[-1,0],[0,1],[0,-1]];
const KEYMAP = {
ArrowUp:[0,-1], ArrowDown:[0,1], ArrowLeft:[-1,0], ArrowRight:[1,0],
w:[0,-1], s:[0,1], a:[-1,0], d:[1,0],
W:[0,-1], S:[0,1], A:[-1,0], D:[1,0]
};
const cv = document.getElementById('cv');
const ctx = cv.getContext('2d');
const overlay = document.getElementById('overlay');
const banner = document.getElementById('banner');
const bannerText = document.getElementById('bannerText');
const scoreEl = document.getElementById('score');
const bestEl = document.getElementById('best');
const lenEl = document.getElementById('len');
const mTitle = document.getElementById('mTitle');
const mScore = document.getElementById('mScore');
const mBest = document.getElementById('mBest');
const btn = document.getElementById('btn');
let CELL = 32, boardPx = 640, dpr = 1;
// ---------- state ----------
let snake = [], prevBody = [], dir = {x:1,y:0}, queue = [];
let food = {x:0,y:0};
let score = 0, best = 0, alive = true, paused = false, started = false, demo = true;
let stepInterval = 135, acc = 0, alpha = 1, lastT = performance.now();
let particles = [], ripples = [];
// ---------- sizing ----------
function resize(){
const rect = cv.getBoundingClientRect();
const w = Math.max(120, rect.width);
dpr = Math.min(window.devicePixelRatio || 1, 2);
boardPx = w;
CELL = w / GRID;
cv.width = Math.round(w * dpr);
cv.height = Math.round(w * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
if (window.ResizeObserver) new ResizeObserver(resize).observe(cv);
window.addEventListener('resize', resize);
// ---------- helpers ----------
const key = (x,y) => y * GRID + x;
const occupied = () => new Set(snake.map(s => key(s.x, s.y)));
function spawnFood(){
const free = [];
for (let y = 0; y < GRID; y++)
for (let x = 0; x < GRID; x++)
if (!snake.some(s => s.x === x && s.y === y)) free.push({x, y});
food = free.length ? free[(Math.random() * free.length) | 0] : {x:0, y:0};
}
function reset(asDemo){
snake = [{x:9,y:10},{x:8,y:10},{x:7,y:10}];
prevBody = [];
dir = {x:1,y:0}; queue = [];
score = 0; alive = true; paused = false;
demo = !!asDemo; started = !asDemo;
stepInterval = 135; acc = 0;
particles = []; ripples = [];
spawnFood();
hideModal();
if (asDemo){
banner.classList.remove('hide');
bannerText.textContent = 'Demo running — click or press a key to start';
} else {
banner.classList.add('hide');
}
updateHUD(true);
}
// ---------- input ----------
function pushDir(nd){
const last = queue.length ? queue[queue.length - 1] : dir;
if (nd.x === -last.x && nd.y === -last.y) return; // never reverse
if (nd.x === last.x && nd.y === last.y) return; // no duplicates
if (queue.length >= 2) return; // keep queue short & safe
queue.push(nd);
}
function takeOver(){
if (started && !demo) return;
started = true; demo = false;
banner.classList.add('hide');
bannerText.textContent = 'You are in control!';
}
window.addEventListener('keydown', (e) => {
const k = e.key;
if (k === ' ' || k === 'Spacebar' || k === 'Enter') e.preventDefault();
if (k === 'p' || k === 'P'){
if (started && !demo && alive){ paused = !paused; paused ? showModal('PAUSED') : hideModal(); }
return;
}
if (k === ' ' || k === 'Spacebar' || k === 'Enter'){
if (!alive || !started){
reset(false); // restart as a player game
} else if (paused){
paused = false; hideModal();
}
return;
}
const m = KEYMAP[k];
if (!m) return;
e.preventDefault();
if (!alive) { reset(false); return; }
if (!started) takeOver();
if (paused) return;
pushDir({x:m[0], y:m[1]});
}, {passive:false});
cv.addEventListener('pointerdown', () => {
if (!alive){ reset(false); return; }
if (!started) takeOver();
});
btn.addEventListener('click', () => reset(false));
// ---------- HUD / modal ----------
function updateHUD(instant){
scoreEl.textContent = score;
bestEl.textContent = best;
lenEl.textContent = snake.length;
if (!instant){
[scoreEl, bestEl, lenEl].forEach(el => {
el.classList.remove('pop'); void el.offsetWidth; el.classList.add('pop');
});
}
}
function showModal(title){
mTitle.textContent = title;
mTitle.classList.toggle('paused', title === 'PAUSED');
mScore.textContent = score;
mBest.textContent = best;
overlay.style.display = 'grid';
}
function hideModal(){ overlay.style.display = 'none'; }
// ---------- simple pathfinding AI (demo mode) ----------
function bfs(start, goal, blocked){
const prev = new Map(), seen = new Set([key(start.x, start.y)]);
const q = [start]; let found = false;
while (q.length){
const c = q.shift();
if (c.x === goal.x && c.y === goal.y){ found = true; break; }
for (const [dx,dy] of DIRS){
const nx = c.x + dx, ny = c.y + dy;
if (nx < 0 || ny < 0 || nx >= GRID || ny >= GRID) continue;
const k = key(nx, ny);
if (blocked.has(k) || seen.has(k)) continue;
seen.add(k); prev.set(k, c); q.push({x:nx, y:ny});
}
}
if (!found) return null;
const path = []; let cur = {x:goal.x, y:goal.y};
while (!(cur.x === start.x && cur.y === start.y)){
path.unshift(cur);
const p = prev.get(key(cur.x, cur.y));
if (!p) return null;
cur = p;
}
return path;
}
function floodSize(sx, sy, blocked){
const seen = new Set([key(sx, sy)]); const q = [{x:sx, y:sy}]; let n = 0;
while (q.length && n < 400){
const c = q.shift(); n++;
for (const [dx,dy] of DIRS){
const nx = c.x + dx, ny = c.y + dy;
if (nx < 0 || ny < 0 || nx >= GRID || ny >= GRID) continue;
const k = key(nx, ny);
if (blocked.has(k) || seen.has(k)) continue;
seen.add(k); q.push({x:nx, y:ny});
}
}
return n;
}
function aiDir(){
const head = snake[0];
const blocked = occupied();
const path = bfs(head, food, blocked);
if (path && path.length){
const t = path[0];
const d = {x:t.x - head.x, y:t.y - head.y};
if (!(d.x === -dir.x && d.y === -dir.y)){
const b2 = new Set(blocked); b2.delete(key(t.x, t.y));
const isFood = t.x === food.x && t.y === food.y;
if (isFood || floodSize(t.x, t.y, b2) > 14) return d;
}
}
let bestD = null, bestS = -1;
for (const [dx,dy] of DIRS){
if (dx === -dir.x && dy === -dir.y) continue;
const nx = head.x + dx, ny = head.y + dy;
if (nx < 0 || ny < 0 || nx >= GRID || ny >= GRID) continue;
if (blocked.has(key(nx, ny))) continue;
const b2 = new Set(blocked); b2.delete(key(nx, ny));
const s = floodSize(nx, ny, b2);
if (s > bestS){ bestS = s; bestD = {x:dx, y:dy}; }
}
return bestD || {x:dir.x, y:dir.y};
}
// ---------- simulation ----------
function step(){
if (demo) pushDir(aiDir());
const d = queue.length ? queue.shift() : dir;
if (d.x === -dir.x && d.y === -dir.y) { /* safety: ignore reverse */ }
else dir = d;
const head = snake[0];
const nx = head.x + dir.x, ny = head.y + dir.y;
if (nx < 0 || ny < 0 || nx >= GRID || ny >= GRID){ die(); return; }
const eating = food.x === nx && food.y === ny;
const limit = eating ? snake.length : snake.length - 1;
for (let i = 0; i < limit; i++){
if (snake[i].x === nx && snake[i].y === ny){ die(); return; }
}
prevBody = snake.slice();
snake.unshift({x:nx, y:ny});
if (eating){
score += 10;
if (score > best) best = score;
burst(food.x, food.y);
ripples.push({x:food.x, y:food.y, r:CELL*0.3, max:CELL*2.1, life:1});
stepInterval = Math.max(68, 135 - (snake.length - 3) * 2.4);
spawnFood();
updateHUD(false);
} else {
snake.pop();
}
}
function die(){
alive = false;
if (demo){
showModal('DEMO OVER');
bannerText.textContent = 'Demo restarting…';
banner.classList.remove('hide');
setTimeout(() => { if (demo && !alive) reset(true); }, 1100);
} else {
showModal('GAME OVER');
}
}
// ---------- particles ----------
function burst(cx, cy){
const colors = ['#b8ff6b','#ffd166','#ff6b81','#ffffff','#7ef2c4'];
for (let i = 0; i < 18; i++){
const a = Math.random() * Math.PI * 2;
const sp = 60 + Math.random() * 210;
particles.push({
x:(cx + 0.5) * CELL, y:(cy + 0.5) * CELL,
vx: Math.cos(a) * sp, vy: Math.sin(a) * sp - 40,
life: 0.45 + Math.random() * 0.45, max: 0.9,
size: CELL * (0.06 + Math.random() * 0.12),
color: colors[(Math.random() * colors.length) | 0]
});
}
}
// ---------- rendering ----------
function renderPos(i){
const to = snake[i];
if (!prevBody.length) return to;
let from;
if (i === 0) from = prevBody[0];
else from = prevBody[Math.min(i - 1, prevBody.length - 1)];
if (!from) return to;
return {x: from.x + (to.x - from.x) * alpha, y: from.y + (to.y - from.y) * alpha};
}
function roundRect(x, y, w, h, r){
ctx.beginPath();
ctx.moveTo(x + r, y);
ctx.arcTo(x + w, y, x + w, y + h, r);
ctx.arcTo(x + w, y + h, x, y + h, r);
ctx.arcTo(x, y + h, x, y, r);
ctx.arcTo(x, y, x + w, y, r);
ctx.closePath();
}
function draw(t){
const W = boardPx;
// background
const bg = ctx.createLinearGradient(0, 0, W, W);
bg.addColorStop(0, '#0a1120'); bg.addColorStop(0.55, '#080d18'); bg.addColorStop(1, '#0b1424');
ctx.fillStyle = bg; ctx.fillRect(0, 0, W, W);
// subtle checker texture
ctx.fillStyle = 'rgba(255,255,255,0.016)';
for (let y = 0; y < GRID; y++)
for (let x = 0; x < GRID; x++)
if ((x + y) % 2 === 0) ctx.fillRect(x * CELL, y * CELL, CELL, CELL);
// grid lines
ctx.strokeStyle = 'rgba(140,180,255,0.055)'; ctx.lineWidth = 1;
ctx.beginPath();
for (let i = 0; i <= GRID; i++){
const p = Math.round(i * CELL) + 0.5;
ctx.moveTo(p, 0); ctx.lineTo(p, W);
ctx.moveTo(0, p); ctx.lineTo(W, p);
}
ctx.stroke();
// vignette
const vg = ctx.createRadialGradient(W/2, W/2, W*0.25, W/2, W/2, W*0.72);
vg.addColorStop(0, 'rgba(0,0,0,0)'); vg.addColorStop(1, 'rgba(0,0,0,0.55)');
ctx.fillStyle = vg; ctx.fillRect(0, 0, W, W);
// ripples
for (const rp of ripples){
ctx.strokeStyle = `rgba(184,255,107,${(rp.life * 0.55).toFixed(3)})`;
ctx.lineWidth = Math.max(1.5, CELL * 0.09 * rp.life);
ctx.beginPath(); ctx.arc((rp.x + 0.5) * CELL, (rp.y + 0.5) * CELL, rp.r, 0, Math.PI * 2); ctx.stroke();
}
// food
const fx = (food.x + 0.5) * CELL, fy = (food.y + 0.5) * CELL;
const pulse = 0.5 + 0.5 * Math.sin(t / 260);
ctx.save();
ctx.shadowColor = 'rgba(255,90,110,0.95)'; ctx.shadowBlur = 16 + pulse * 12;
const fg = ctx.createRadialGradient(fx - CELL*0.11, fy - CELL*0.13, CELL*0.04, fx, fy, CELL*0.34);
fg.addColorStop(0, '#ffe3e8'); fg.addColorStop(0.32, '#ff6b81'); fg.addColorStop(1, '#c9184a');
ctx.fillStyle = fg;
ctx.beginPath(); ctx.arc(fx, fy, CELL * (0.29 + pulse * 0.025), 0, Math.PI * 2); ctx.fill();
ctx.restore();
ctx.strokeStyle = '#6ee7a0'; ctx.lineWidth = Math.max(2, CELL * 0.075); ctx.lineCap = 'round';
ctx.beginPath(); ctx.moveTo(fx, fy - CELL*0.26);
ctx.quadraticCurveTo(fx + CELL*0.13, fy - CELL*0.42, fx + CELL*0.03, fy - CELL*0.47); ctx.stroke();
// snake
const pts = snake.map((_, i) => {
const p = renderPos(i);
return {x:(p.x + 0.5) * CELL, y:(p.y + 0.5) * CELL};
});
const head = pts[0], tail = pts[pts.length - 1];
// soft glow under snake
ctx.save();
ctx.strokeStyle = 'rgba(61,220,132,0.22)';
ctx.lineWidth = CELL * 1.02; ctx.lineJoin = 'round'; ctx.lineCap = 'round';
ctx.beginPath(); ctx.moveTo(head.x, head.y);
for (let i = 1; i < pts.length; i++) ctx.lineTo(pts[i].x, pts[i].y);
if (pts.length === 1) ctx.lineTo(head.x + 0.01, head.y);
ctx.stroke();
ctx.restore();
// body
const g = ctx.createLinearGradient(head.x, head.y, tail.x, tail.y);
if (alive){
g.addColorStop(0, '#d6ff8e'); g.addColorStop(0.35, '#6ff08c');
g.addColorStop(0.75, '#25b46b'); g.addColorStop(1, '#12764a');
} else {
g.addColorStop(0, '#ffb4b4'); g.addColorStop(0.5, '#e05b63'); g.addColorStop(1, '#7a2730');
}
ctx.strokeStyle = g;
ctx.lineWidth = CELL * 0.74; ctx.lineJoin = 'round'; ctx.lineCap = 'round';
ctx.beginPath(); ctx.moveTo(head.x, head.y);
for (let i = 1; i < pts.length; i++) ctx.lineTo(pts[i].x, pts[i].y);
ctx.stroke();
// glossy ridge
ctx.strokeStyle = 'rgba(255,255,255,0.13)';
ctx.lineWidth = CELL * 0.22; ctx.lineJoin = 'round'; ctx.lineCap = 'round';
ctx.beginPath(); ctx.moveTo(head.x, head.y);
for (let i = 1; i < pts.length; i++) ctx.lineTo(pts[i].x, pts[i].y);
ctx.stroke();
// head
const hx = head.x, hy = head.y;
const hg = ctx.createRadialGradient(hx - CELL*0.14, hy - CELL*0.16, CELL*0.05, hx, hy, CELL*0.48);
if (alive){ hg.addColorStop(0, '#f2ffd2'); hg.addColorStop(0.45, '#a9ff7d'); hg.addColorStop(1, '#3fce72'); }
else { hg.addColorStop(0, '#ffe0e0'); hg.addColorStop(0.5, '#ff8b8b'); hg.addColorStop(1, '#b13c48'); }
ctx.fillStyle = hg;
ctx.beginPath(); ctx.arc(hx, hy, CELL * 0.42, 0, Math.PI * 2); ctx.fill();
// eyes
const dx = dir.x, dy = dir.y;
const px = -dy, py = dx; // perpendicular
const eF = CELL * 0.17, eS = CELL * 0.205, eR = CELL * 0.135;
for (const s of [1, -1]){
const ex = hx + dx * eF + px * eS * s;
const ey = hy + dy * eF + py * eS * s;
ctx.fillStyle = '#ffffff';
ctx.beginPath(); ctx.arc(ex, ey, eR, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = '#0b1220';
ctx.beginPath();
ctx.arc(ex + dx * CELL * 0.055, ey + dy * CELL * 0.055, eR * 0.55, 0, Math.PI * 2);
ctx.fill();
}
// particles
for (const p of particles){
const a = Math.max(0, Math.min(1, p.life / p.max));
ctx.globalAlpha = a;
ctx.fillStyle = p.color;
ctx.beginPath(); ctx.arc(p.x, p.y, p.size * (0.4 + a * 0.8), 0, Math.PI * 2); ctx.fill();
}
ctx.globalAlpha = 1;
}
// ---------- main loop ----------
function frame(t){
const dt = Math.min(80, t - lastT); lastT = t;
if (alive && !paused){
acc += dt;
let guard = 0;
while (acc >= stepInterval && alive && guard++ < 5){
acc -= stepInterval;
step();
}
if (!alive) acc = 0;
alpha = alive ? Math.max(0, Math.min(1, acc / stepInterval)) : 1;
} else {
alpha = 1;
}
const s = dt / 1000;
for (const p of particles){ p.x += p.vx * s; p.y += p.vy * s; p.vy += 320 * s; p.life -= s; }
particles = particles.filter(p => p.life > 0);
for (const rp of ripples){ rp.r += CELL * 2.4 * s; rp.life -= s * 1.9; }
ripples = ripples.filter(rp => rp.life > 0);
draw(t);
requestAnimationFrame(frame);
}
// ---------- boot ----------
resize();
reset(true);
requestAnimationFrame(frame);
})();
</script>
</body>
</html>Reasoning trace (10,040 tokens)
Let me build a polished Snake game.
Key details:
- Canvas grid at least 20x20 cells, centered.
- Arrow keys and WASD.
- Quick successive key presses both honored → use a direction queue (input buffer). But no reversing. Classic solution: keep a queue of pending directions; when processing, validate against last applied direction; allow up to 2 queued turns.
- Food: +10 points, grows by one cell, doesn't spawn on snake.
- Game over overlay with score, Space/Enter restarts, P pauses.
- Smooth movement: interpolate snake rendering between grid positions. Use fixed timestep for logic and render interpolation.
- Head with eyes facing direction.
- Particle burst on eating.
- Score and best score (session best, keep in memory variable, no localStorage).
Let me design:
Grid 24x24 cells, cell size maybe 26px → 624px canvas. Plus padding. Actually let's make canvas responsive: fixed internal resolution of e.g. 720x720 with cell 30, grid 24x24 = 720. Draw with devicePixelRatio scaling. On small screens, scale down via CSS (max-width: 100%). But key handling and rendering coordinate fine.
Actually for responsiveness, better: canvas CSS width = min(100%, 640px), aspect ratio 1:1. Internal buffer = 640*dpr. Draw grid in CSS pixels scaled.
Let me set: GRID = 22 (at least 20x20). CELL = 640/22 ≈ 29.09. Hmm, non-integer is fine but let's use GRID=20, CELL=32, board=640. 20x20 meets requirement exactly. Let's do 22 for a bit more? 20x20 is fine and clean. Actually let's do 20x20 at 32px = 640px canvas. Good.
Maybe make board responsive: compute cell size from container width so on mobile it fits. Use CSS: canvas { width: 100%; max-width: 640px; aspect-ratio: 1/1 }. And JS resize handler to set backing store based on clientWidth * dpr. Draw using logical board size = clientWidth (CSS px), cell = clientWidth/GRID. That handles scaling well.
Layout: page centered flex column; header with title + score/best badges; canvas in a rounded panel with glow; footer with controls hint; overlay for game over / pause drawn on canvas (or as DOM overlay). DOM overlay is easier and prettier. But requirement says "overlay showing the score" — DOM overlay works.
Game loop: requestAnimationFrame with accumulator for fixed step (e.g., speed 120ms per step, decreasing slightly as length grows). Interpolation for smooth rendering: render position = lerp(prev, current, alpha).
Snake data: array of {x,y} cells, head first. On step: compute new head from direction; check collision; unshift new head; if food eaten, don't pop tail, grow; else pop tail.
Direction queue: array of pending direction objects. On key: determine new dir; compare to the last queued direction (or current dir if queue empty); if opposite → ignore. Also can't do 180 in one step: with queue, e.g., moving right, press up then left quickly — up queued, left queued. On next step apply up (last becomes up). Then next step apply left — that's legal since up→left isn't reverse. But the queue length should be capped at 2 to prevent reversal issues. Actually with the rule "new dir can't be opposite of last queued dir", pressing right then left then up... let's see: current right. Press left → opposite of right → rejected. Press up → queued [up]. Then press left → opposite of up → rejected? Left is opposite of right, not up. So [up, left] allowed. Good.
Cap queue at 2 entries.
Smooth movement with interpolation and turns: when the head turns, interpolation from previous cell to new cell works fine.
Rendering snake: rounded body segments, gradient, head slightly bigger with eyes (white sclera + dark pupil) offset according to direction, plus a tongue maybe. Body segments interpolated positions between their previous and current grid positions — for smooth continuous motion, I can store each segment's previous position. Standard approach: keep snake as list of cells; when rendering, segment i's render position = lerp(snake[i].prev, snake[i].cur, alpha) where prev is the cell it was at before the last step. Simpler: compute render position of segment i from the tail-following: segment i occupies path. Let me just store for each body part a "from" position.
Alternative simpler approach that looks smooth: render the snake as a continuous polyline through interpolated points. Compute points array where point[0] = head render pos, point[i] = lerp(prevPos[i], curPos[i], alpha).
Let me store snake as array of objects {x, y, px, py} where px,py is position before last step. On step: for each segment, px=x, py=y then shift etc. Careful with growth: when growing, tail stays — its prev should become its current? Hmm.
Simpler: maintain `body` array of cells (grid coords) and a separate `prevBody` array snapshot of body before each step. Then render: for i in body: from = prevBody[i] ?? body[i]; renderPos = lerp(from, body[i]). When growing, body length increases by 1 at the end (tail), prevBody has length L, body has L+1 → last segment's from = prevBody[L-1]?? That's wrong; the tail didn't move. Let's handle: when growing, we unshift new head and don't pop tail, so body length L+1. prevBody (length L) maps: segment i (0..L-1) came from prevBody[i], and segment L (the old tail) is at prevBody[L-1]... wait old body was [h, s1, ..., s_{L-1}], new body = [newHead, h, s1, ..., s_{L-1}]. So new body[i] = old body[i-1] for i>=1, and new body[0]=newHead. prevBody = old body. So for i>=1, from = prevBody[i-1], to = body[i]. For i=0, from = prevBody[0] (old head) → new head. For i = L (new length L+1), from = prevBody[L-1] (old tail) → body[L]=old tail. So from = prevBody[i-1] for i>=1, but for i = L, prevBody[L-1] = body[L] → stationary. Good, consistent: from = prevBody[min(i-1, L-1)].
Generally without growth: new body[i] = old body[i-1] for i>=1, body[0] = newHead, and length stays L, tail popped. from(i) = prevBody[i-1] for i>=1, from(0) = prevBody[0]. For i = L-1 (new tail) from = prevBody[L-2] = old body[L-2] which was second-to-last tail. Correct: old tail moved to new tail position? Old body: [h,...,s_{L-1}] tail at s_{L-1}. New body after moving: [newHead, h, ..., s_{L-2}], so new tail is s_{L-2}, which was old second-to-last. Yes from = old body[L-2] = prevBody[L-2]. ✓.
So unified: from_i = prevBody[max(0, i-1)] (clamped to prevBody length-1), to_i = body[i]. Where prevBody length L (before step) and body length L or L+1. For i up to L: from index = min(i-1, L-1). ✓ for i=0 → 0? min(-1...) → use max(0, i-1)=0 → prevBody[0] = old head. ✓
Great.
Head eyes: pupils facing direction. Draw head circle rounded rect with slightly bigger radius, then two white eyes positioned perpendicular to direction, offset forward, pupils offset in direction.
Particles: array of {x,y,vx,vy,life,color,size}. Update in rAF with dt.
Food: draw as glowing apple/berry with pulse: circle with radial gradient + shimmer.
Also add grid lines subtly, background dark, rounded corners, vignette.
Overlay: DOM div positioned absolutely over canvas with backdrop blur, showing GAME OVER / score / best, "Press Space or Enter to restart". Pause overlay "PAUSED".
Start state: "Click or press a key to start" overlay, but game should auto-play demo mode behind? The system prompt says games must start in a self-playing demo mode behind a "Click or press a key to start" message. But requirement says game over overlay with score, Space/Enter restarts. Hmm — conflicting slightly: the auto-play demo requirement is a general guideline from the harness. I'll implement: initial screen shows "Click or press a key to start" and behind it an AI plays the snake automatically (demo mode) so it's visible in action. When user presses a key, switch to player control.
But then the "game over" overlay appears when the AI dies during demo — that would show score and ask restart, which would block. Hmm. In demo mode, if AI dies, restart demo automatically after a moment and show a banner? Let's handle: in demo mode, on death, show overlay "GAME OVER" briefly? The screenshot is taken at ~3s, so likely game still running demo. Let's make demo mode robust AI so it doesn't die quickly. AI: greedy toward food with safety check (avoid immediate collision after turn). Implement a simple pathing: BFS from head to food avoiding body; take first step of path; if no path, follow tail. That's decent.
Let me implement AI with BFS on grid excluding body cells (treat tail as free-ish). Then fallback: choose a safe direction maximizing free space.
During demo mode, show a subtle "DEMO — press any key to play" banner. When user presses arrow/WASD, enter player mode, hide banner.
Also the requirement "Game over when snake hits wall or itself, with an overlay showing the score; Space or Enter restarts" — in player mode, death → overlay with score & best, Space/Enter restarts in player mode.
In demo mode death → restart demo automatically (with a brief overlay maybe showing "DEMO OVER" then continue). Keep it simple: auto restart demo immediately.
Let's write the AI BFS.
Board 20x20 = 400 cells; BFS each move is cheap.
AI:
```
function aiDirection() {
const bodySet = new Set(snake.map(s=>s.y*GRID+s.x));
// BFS from head
... find path to food, take first step
// if food unreachable or path's first step unsafe, pick safe neighbor
}
```
Safety: ensure the chosen cell is not a wall and not body (except tail). Additionally after moving, check that the resulting head has at least one free neighbor... good enough.
Additionally avoid moving into a cell where head would be trapped. Keep simple.
Implementation of BFS returning path array.
Also the AI must handle growth.
Let's write the code.
```js
const GRID = 20;
let CELL, boardPx, dpr;
```
Canvas sizing: canvas element with CSS width determined by container. Use ResizeObserver or window resize to set canvas.width = rect.width*dpr, canvas.height = same, and ctx.setTransform(dpr,0,0,dpr,0,0). Then CELL = rect.width/GRID.
Draw functions use logical px = rect.width.
Game state:
- snake: array of {x,y}
- prevBody
- dir {x,y}
- queue []
- food {x,y}
- score, best, alive, paused, started, demo
- stepTimer
Timing: base step interval 130ms, decrease as length grows: interval = max(65, 130 - (length-3)*2.5).
Smoothness: use interpolation alpha = accumulator/stepInterval.
Turn rendering issue: When direction changes at a step, the head path from old head to new head is a straight line — fine.
For smooth turns at corners, the head moves along grid cells; fine.
Particles on eat: 14 particles from food position, colors.
Also add screen shake on death? Small. Keep subtle.
Let me also add a subtle "eat flash" ripple ring on canvas.
Score display: DOM elements with big numbers, animated pulse on change.
Let me write full HTML.
Styles: dark theme with gradient background, neon accents, font-family system/ui-monospace. Use Google Fonts? Not allowed external except CDN explicitly allowed by task — the task doesn't mention fonts. Use system fonts to be safe.
Layout:
```
body { display:flex; align-items:center; justify-content:center; min-height:100vh; }
.wrap { width: min(94vw, 680px); }
header { display:flex; justify-content:space-between; align-items:center; }
.board-frame { position:relative; border-radius:18px; overflow:hidden; box-shadow: ...; }
canvas { display:block; width:100%; aspect-ratio:1/1; }
```
Overlay inside .board-frame absolute inset 0 flex center, with backdrop-filter blur.
Footer: controls legend.
Mobile: 375px → board ~345px, fine.
Let me write the code carefully.
Detailed code:
```html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Neon Snake</title>
<style>...</style>
</head>
<body>
<div class="app">
<header class="hud">
<div class="brand"><span class="logo">🐍</span><h1>SNAKE</h1></div>
<div class="stats">
<div class="stat"><span class="label">SCORE</span><span class="value" id="score">0</span></div>
<div class="stat"><span class="label">BEST</span><span class="value best" id="best">0</span></div>
</div>
</header>
<div class="frame" id="frame">
<canvas id="cv"></canvas>
<div class="overlay" id="ov">
<div class="card" id="card">...</div>
</div>
</div>
<footer>...</footer>
</div>
<script>...</script>
</body>
```
Now the JS.
```js
const GRID = 20;
const cv = document.getElementById('cv');
const ctx = cv.getContext('2d');
let boardPx = 600, dpr = 1, CELL = 30;
function resize(){
const rect = cv.getBoundingClientRect();
dpr = Math.min(window.devicePixelRatio||1, 2);
boardPx = rect.width;
cv.width = Math.round(rect.width*dpr);
cv.height = Math.round(rect.width*dpr);
ctx.setTransform(dpr,0,0,dpr,0,0);
CELL = rect.width/GRID;
}
window.addEventListener('resize', resize);
```
Note: canvas CSS height must equal width; use aspect-ratio: 1/1 and height auto via CSS `width:100%; height:auto;` plus canvas attribute width/height set to same → aspect ratio maintained. Actually setting cv.width changes the intrinsic ratio; if CSS width:100% and height:auto, the height follows intrinsic aspect which is 1:1 since we set both equal. Good. But there's a feedback loop: rect.width depends on layout which depends on canvas width? No, width:100% of parent → independent. Height auto derives from intrinsic ratio → fine.
Use ResizeObserver on cv for robustness.
Game init:
```js
let snake, prevBody, dir, queue, food, score, best=0, alive, paused, demo, particles=[], ripples=[], stepInterval, acc, lastTime;
```
reset(isDemo): snake = [{x:10,y:10},{x:9,y:10},{x:8,y:10}] length 3; dir {x:1,y:0}; queue=[]; score=0; alive=true; paused=false; spawnFood(); stepInterval=130; acc=0;
spawnFood: pick random free cell (not in snake).
step():
```
const d = queue.length ? queue.shift() : dir;
// validate no reverse relative to dir
if (d.x === -dir.x && d.y === -dir.y) return; // shouldn't happen
dir = d;
const head = snake[0];
const nx = head.x + dir.x, ny = head.y + dir.y;
if (nx<0||ny<0||nx>=GRID||ny>=GRID) → die
// check body collision: the tail cell will be removed unless growing. Standard: check against snake body excluding tail if not eating.
const eating = food.x===nx && food.y===ny;
const willGrow = eating;
const bodyToCheck = willGrow ? snake : snake.slice(0, snake.length-1);
if (bodyToCheck.some(s=>s.x===nx&&s.y===ny)) → die
```
Hmm, actually snake.slice(0,len-1) removes tail which moves away — correct.
prevBody = snake.slice();
snake.unshift({x:nx,y:ny});
if (eating) { score+=10; best=Math.max(best,score); burst(food); ripple; stepInterval = Math.max(65, 130-(snake.length-3)*2.2); spawnFood(); } else snake.pop();
```
Wait: best should update in player mode; in demo mode score also counts? Best of session — demo scores could pollute. I'll track best only for player runs? "best score of the session" - I'll track best overall including demo? Better: best = max over player runs, but demo is the first thing shown... Let's keep a single best that includes demo runs but demo AI is decent. Hmm, judges may compare. Simpler and safe: best updates from any run but demo games restart at 0. I'll just let both update best; it's a "best score of the session". Fine.
Actually maybe nicer: demo mode labeled "DEMO" and best reflects overall best. OK.
Death handling:
- player: alive=false; show overlay with score, best, "Space / Enter to play again".
- demo: alive=false; after 700ms auto reset demo (keep overlay hidden or show "demo restarted"). I'll show a brief flash overlay? Keep simple: reset demo after 600ms without overlay, and set a toast in the overlay area "DEMO RESTART".
Overlay states:
- state 'ready': "Click or press a key to start" — but demo is running behind. So overlay should be a slim banner, not a full blocker, so the demo action is visible. The guidelines say: "Games must start in a self-playing demo mode behind a 'Click or press a key to start' message". So a small translucent banner at the bottom of the board or a non-blocking card. I'll make overlay non-blocking (pointer-events:none except when game over) and show the banner at the top area semi-transparent so the snake is visible.
Let me design: overlay div covering the frame with display flex; for 'ready' state: align-items:flex-start? Put the banner at top with gradient fade. For 'over' state: full center card with blur.
Simplify: two separate elements: #banner (top pill) and #modal (center card). Ready → banner visible "DEMO MODE — click or press a key to play"; over → modal visible.
Also for paused: modal shows "PAUSED — press P to resume".
Overlay interactivity: modal should not block keyboard. Keyboard listener on window.
Keyboard: keydown handler; preventDefault for arrows/space to avoid page scroll.
Key handling & demo switch: if (!started) { started = true; demo = false; } then apply direction.
But "click to start" — clicking canvas starts player mode. Clicking should also maybe set direction? Just start.
After user presses a key in demo mode, we switch to player mode. Should we reset the snake? The snake is mid-demo at some position; continuing from there is fine and feels responsive. I'll keep the current state and just switch control. But then direction queue: current dir; user presses up → fine.
Edge: user presses a key opposite to current direction → ignored (no reverse). Fine.
Now the AI:
```js
function aiNextDir(){
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); // exclude tail
// BFS from head to food
const path = bfs(head, food, blocked);
if (path && path.length) {
const next = path[0];
if (isSafe(next.x,next.y,blocked)) return {x:next.x-head.x, y:next.y-head.y};
}
// fallback: choose neighbor that is safe and has most space
...
}
```
BFS returns list of cells from head to food exclusive of head, include food.
isSafe(x,y): in bounds, not in blocked (excluding tail? tail moves away so it's ok to step into tail cell). We excluded tail from blocked so ok. Additionally check that after moving there, there's a free neighbor (not trapped). Let's compute for candidate: count free neighbors of the target cell in blocked set; require >=1 (or if target is food, fine).
Let me write BFS with array queue and parent map.
```js
function bfs(start, goal, blocked){
const key=(x,y)=>y*GRID+x;
const prev=new Map(); const q=[start]; const seen=new Set([key(start.x,start.y)]);
let found=false;
while(q.length){
const c=q.shift();
if(c.x===goal.x&&c.y===goal.y){found=true;break;}
for(const [dx,dy] of DIRS){
const nx=c.x+dx, ny=c.y+dy;
if(nx<0||ny<0||nx>=GRID||ny>=GRID) continue;
const k=key(nx,ny);
if(blocked.has(k)||seen.has(k)) continue;
seen.add(k); prev.set(k,c); q.push({x:nx,y:ny});
}
}
if(!found) return null;
// reconstruct
let path=[]; let cur={x:goal.x,y:goal.y};
while(!(cur.x===start.x&&cur.y===start.y)){
path.unshift(cur);
cur=prev.get(key(cur.x,cur.y));
if(!cur) return null;
}
return path;
}
```
Careful: goal (food) is never blocked so fine.
Fallback: evaluate each direction: target in bounds, not blocked, and freeSpace(target) >= some. Compute flood fill free space count for each candidate, pick max. If none safe → any direction (will die eventually). Implement flood fill limited.
Also, the AI must not reverse into itself: exclude opposite of current dir.
Let me implement freeSpace via BFS flood count limited to 200 cells.
Now rendering.
draw():
- clear with background gradient (dark navy), grid lines subtle.
- draw food with glow pulse.
- draw ripples (expanding circles fading).
- draw snake body: for each segment render position; draw rounded rect/circles. Use a path through segments for continuous look? Draw each segment as rounded square with radius proportional to cell, using per-segment fill gradient. Gaps at turns might appear for squares; using circles with radius ~ CELL*0.42 gives a smooth continuous snake. I'll use circles for body with slight radius variation (tail thinner). And head as rounded shape larger.
Approach: draw body as thick stroked polyline through render positions, with lineJoin round, lineCap round, color gradient. That gives smooth continuous snake. Then draw head on top with eyes. That looks polished. But stroke line width constant; tail taper not possible easily — draw separate stroke segments with decreasing width? Simpler: stroke the whole polyline with width CELL*0.78 in base color, then overlay segments with gradient.
Let me do:
1. ctx.lineWidth = CELL*0.8; lineJoin='round'; lineCap='round'; strokeStyle = gradient along? Use a linear gradient from head to tail computed from head & tail positions. stroke polyline.
2. Then draw scale highlights: smaller inner stroke with lighter color and alpha for a glossy ridge — maybe stroke lineWidth CELL*0.3 with rgba(255,255,255,0.12) offset? Keep simple: after main stroke, stroke a thinner line with a lighter green to simulate top highlight (alpha 0.25).
3. Head: draw a rounded rect/circle at head render pos with radius CELL*0.46, color bright green gradient; then eyes.
Eyes: two white ellipses positioned at head center + dir*0.18*CELL ± perp*0.20*CELL, radius ~0.13*CELL; pupils dark circles offset toward dir by 0.06*CELL, radius 0.07*CELL.
Also add a tongue occasionally? skip.
Food: circle radius CELL*0.30 with radial gradient (red/pink), plus pulsing outer glow circle, plus a small green leaf/stem. Draw glow via shadowBlur.
Particles: small circles with life fade, gravity slight.
Ripple: expanding stroke circle at eat position.
Score DOM update with pulse animation: add class 'pop' then remove after timeout. Use CSS animation via class toggle with requestAnimationFrame reflow.
Let me also animate the "eat" scale of head? nah.
Now let's handle interpolation: alpha = acc/stepInterval (0..1). Render positions computed via lerp of from/to.
Actually careful: prevBody mapping as discussed: from_i = prevBody[min(i-1, prevBody.length-1)] for i>=1 else prevBody[0]; to_i = snake[i].
Implement:
```
function renderPos(i){
const to = snake[i];
let from;
if(i===0) from = prevBody[0] || to;
else from = prevBody[Math.min(i-1, prevBody.length-1)] || to;
return {x: from.x+(to.x-from.x)*alpha, y: from.y+(to.y-from.y)*alpha};
}
```
Hmm but when growing, body length = prevBody.length+1, so i max = prevBody.length, from index = min(i-1, len-1) = len-1 → prevBody[len-1] = old tail = snake[len] ✓ (snake[len] is old tail). to = snake[len] = old tail. Same → stationary ✓.
When not growing, body length = prevBody.length = L, i max = L-1, from index = L-2 ✓.
Good.
Edge: initial prevBody = snake (so no movement on first frames) — set prevBody = snake.map copy at reset. But then index mapping: prevBody[0] = head, from_0 = prevBody[0]=head ✓; from_1 = prevBody[0]=head, to_1 = snake[1]=second cell → head moves into second cell?? That's wrong for initial. Actually at reset prevBody should be the body one step "before" so that after the first step, prevBody = snake before step (correct mapping). At reset, prevBody = snake means from_i = snake[i-1]... hmm mapping assumed prevBody = body before the step, i.e., after a step, body[i] = oldbody[i-1]. At reset with body = [h, s1, s2], prevBody should be such that prevBody[i-1] = body[i]'s previous position = body[i] itself for the first render before any step? Let's just set prevBody = [] at reset and handle: if prevBody empty → from = to (no motion) for all. Then after first step prevBody = old body, and mapping applies: from_0 = prevBody[0] = old head, to_0 = new head ✓; from_1 = prevBody[0]=old head, to_1 = old head → snake[1] after first step is old head ✓ (body = [newHead, oldHead, s1]). Yes! new body[1] = old body[0] = old head ✓. Good.
So at reset: prevBody = [] → render uses to=to.
Wait, but with prevBody empty, from_1 = prevBody[min(0, -1)] → undefined → fallback to to. Handle with guard.
Let me define: if(prevBody.length===0) return to.
Now stepping uses dir from queue. Queue validation:
```
function pushDir(d){
const last = queue.length ? queue[queue.length-1] : dir;
if(d.x===-last.x && d.y===-last.y) return; // reverse
if(d.x===last.x && d.y===last.y) return; // same
if(queue.length>=2) return;
queue.push(d);
}
```
Also prevent two lefts? Not needed.
Note the reverse check relative to `last` handles quick successive presses properly: moving right, press up (queue [up]), press left: last = up, left isn't opposite of up → allowed → queue [up,left]. Then step1 applies up (dir=up), step2 applies left (not opposite of up) ✓. Good.
But subtle: moving right, press up then press right? last=up, right != -up → allowed → queue [up, right] → step1 up, step2 right → legal (up→right not reverse) ✓.
Also pressing left while moving right is rejected since left == -right ✓.
Good.
Speed: stepInterval base 135ms; decrease with length to min 70.
Demo AI updates each step before moving.
Now the game loop with fixed step and interpolation. Use rAF:
```
let last = performance.now();
function frame(t){
const dt = Math.min(100, t-last); last=t;
if(alive && !paused){
acc += dt;
while(acc >= stepInterval){ acc -= stepInterval; step(); if(!alive) {acc=0;break;} }
alpha = acc/stepInterval;
} else alpha = 1;
updateParticles(dt);
draw();
requestAnimationFrame(frame);
}
```
But stepInterval changes inside step; fine.
Particles update should use dt/1000 for velocity in px per second.
Now let me write draw in detail with logical pixel coordinates.
Background:
```
ctx.fillStyle = '#0a0f1c'; fillRect
```
Draw a subtle radial gradient vignette, and grid lines with rgba(255,255,255,0.04).
Board border glow in CSS frame.
Food draw:
```
const fx = (food.x+0.5)*CELL, fy=(food.y+0.5)*CELL;
const pulse = 0.5+0.5*Math.sin(t/300);
ctx.save();
ctx.shadowColor='rgba(255,90,110,0.9)'; ctx.shadowBlur=18;
const g=ctx.createRadialGradient(fx-CELL*0.1, fy-CELL*0.12, CELL*0.05, fx, fy, CELL*0.34);
g.addColorStop(0,'#ffd7dc'); g.addColorStop(0.35,'#ff6b81'); g.addColorStop(1,'#c9184a');
ctx.fillStyle=g; ctx.beginPath(); ctx.arc(fx,fy,CELL*0.30*(0.95+0.05*pulse),0,Math.PI*2); ctx.fill();
ctx.restore();
// stem
ctx.strokeStyle='#6ee7a0'; ctx.lineWidth=Math.max(2,CELL*0.07); ctx.lineCap='round';
ctx.beginPath(); ctx.moveTo(fx, fy-CELL*0.26); ctx.quadraticCurveTo(fx+CELL*0.12, fy-CELL*0.42, fx+CELL*0.02, fy-CELL*0.46); ctx.stroke();
```
Snake body polyline: build points array from renderPos for i=0..n-1. Stroke with gradient from head to tail.
```
const pts = snake.map((_,i)=>renderPos(i));
const grad = ctx.createLinearGradient(pts[0].x*CELL? ...)
```
Positions are in cell units; multiply by CELL.
Gradient: createLinearGradient(headPx, headPy, tailPx, tailPy) with colors: head '#b8ff6b' → '#3ddc84' → tail '#1b7a4b'.
Stroke width CELL*0.72, lineCap round, lineJoin round. Then a glossy inner stroke width CELL*0.26 with 'rgba(255,255,255,0.10)'.
Head: circle radius CELL*0.40 at head point, gradient bright. Plus eyes.
Eyes: perp = (-dir.y, dir.x). eye offset forward = 0.16*CELL, side = 0.20*CELL.
eye pos = head + dir*0.16*CELL + perp*side*±1.
Draw white circle radius CELL*0.14, then pupil dark circle at eye + dir*0.05*CELL radius CELL*0.075.
Maybe blink animation occasionally — skip.
Dead state: draw snake with dimmer color / red? Add overlay modal anyway.
Now DOM overlay modal content:
```
<div class="modal" id="modal" hidden>
<div class="m-title" id="mTitle">GAME OVER</div>
<div class="m-score"><span id="mScore">0</span><small>SCORE</small></div>
<div class="m-best">BEST <span id="mBest">0</span></div>
<button id="btn">Play again</button>
<div class="m-hint">Space / Enter to restart · P to pause</div>
</div>
```
Buttons: clicking the button restarts (mouse works). But window.open etc blocked — buttons fine.
Add a pause button in HUD too maybe. Add small buttons: Pause (P). Keep.
HUD: score, best, and length maybe. Add length as third stat? "Display the current score and the best score" — score & best required. Add length as extra nice.
Now, banner for demo: top of frame:
```
<div class="banner" id="banner"><span class="dot"></span> DEMO — click or press any key to play</div>
```
With pointer-events none.
After user starts, banner hides.
Also a "PAUSED" indicator.
Let's write CSS with polish: gradient background, glassmorphism cards, neon glow text.
Let me write the final code.
One more consideration: the screenshot at 3 seconds. Demo mode running with AI — snake visible moving, food visible. Good.
Also ensure canvas has content immediately (first paint). Demo starts right away.
Let me write the AI's initial direction: dir right, snake at center. AI will path to food.
Let me also ensure food spawns at a reasonable place.
Potential AI bug: BFS treats tail as free (excluded from blocked). Path might route through the tail cell — acceptable.
Also the AI path may lead to a trap where the head's next move is blocked; we check isSafe for the first step: target not blocked (body minus tail) and after moving, at least one free neighbor OR it's the food. Let's implement freeNeighborCount(target, blocked) counting in-bounds cells not in blocked (blocked excludes tail) — if 0 and target isn't food → unsafe.
Hmm, but the tail cell is not blocked, so count includes tail → at least 1 → always "safe". Use a stricter blocked set including tail for the safety check. Let's make two sets: blockedBody (all body incl. tail) and use that for BFS? If tail is included in BFS blocked, the path is safer. But when the snake doesn't grow, the tail moves away, so stepping into the old tail position is actually legal. Using strict blocking might make AI avoid good shortcuts but it's safe. I'll use strict (include tail) for both BFS and safety → conservative and safe. Might occasionally fail to reach food near tail; fallback handles.
Fallback: choose direction maximizing flood-fill free space, excluding reverse, requiring target not in strictBlocked. Then also safe-ish. This guarantees the AI survives a long time typically.
Let me code flood space:
```
function floodSize(sx,sy,blocked){
const seen=new Set([sy*GRID+sx]); const q=[{x:sx,y:sy}]; let n=0;
while(q.length){ const c=q.shift(); n++; for(const[dx,dy] of DIRS){const nx=c.x+dx,ny=c.y+dy; if(nx<0||ny<0||nx>=GRID||ny>=GRID)continue; const k=ny*GRID+nx; if(blocked.has(k)||seen.has(k))continue; seen.add(k); q.push({x:nx,y:ny});} }
return n;
}
```
400 cells fine.
AI decision:
```
function aiDir(){
const head=snake[0];
const blocked=new Set(snake.map(s=>s.y*GRID+s.x));
const path=bfs(head,food,blocked);
if(path&&path.length){
const t=path[0];
const d={x:t.x-head.x,y:t.y-head.y};
if(isLegal(d)) {
// verify target has space
const b2=new Set(blocked); b2.delete(t.y*GRID+t.x);
if(floodSize(t.x,t.y,b2) > 12 || t.x===food.x&&t.y===food.y) return d;
}
}
// fallback
let bestD=null,bestScore=-1;
for(const [dx,dy] of DIRS){
if(dx===-dir.x&&dy===-dir.y) continue;
const nx=head.x+dx, ny=head.y+dy;
if(nx<0||ny<0||nx>=GRID||ny>=GRID) continue;
if(blocked.has(ny*GRID+nx)) continue;
const b2=new Set(blocked); b2.delete(ny*GRID+nx);
const s=floodSize(nx,ny,b2);
if(s>bestScore){bestScore=s;bestD={x:dx,y:dy};}
}
return bestD || {x:dir.x,y:dir.y};
}
```
Wait: for the food path check, floodSize from target with the target removed from blocked: >12 threshold. Also should include the snake body minus the target. OK.
isLegal(d): not reverse of dir.
Also note dir for the AI is the snake's current dir.
Also the AI should not immediately reverse — handled.
Now player mode: queue-based.
Let me write everything now.
Additional: "P pauses" — toggle pause. In pause, modal shows PAUSED with "press P to resume". Space in pause? Space restarts only on game over. Let's make Space in pause resume? Spec says Space or Enter restarts (game over), P pauses. I'll make P toggle pause, and in pause show modal "PAUSED".
If game over and user presses P? Ignore.
Restart: Space/Enter → reset with player mode (demo=false).
Also clicking the board when game over → restart.
Edge: when game over in player mode, keydown for space/enter restarts.
Let me handle the "started" state: after restart via Space, started=true, demo=false.
Now write the code.
For the modal button, id="btn" → click → startPlayer().
Also clicking canvas: if !started → start player (keep current snake). If game over → restart.
Let me write final HTML. I'll be thorough but concise.
CSS details:
```css
*{box-sizing:border-box}
html,body{height:100%}
body{
margin:0;
background:
radial-gradient(1200px 800px at 20% -10%, #16233f 0%, transparent 60%),
radial-gradient(900px 700px at 110% 20%, #123a2c 0%, transparent 55%),
#070b14;
color:#e8f0ff;
font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial;
display:flex; align-items:center; justify-content:center;
padding:16px;
min-height:100vh;
}
.app{width:min(96vw,660px); display:flex; flex-direction:column; gap:12px;}
```
HUD: flex row space-between with brand and stats.
.stats .stat { background: rgba(255,255,255,0.05); border:1px solid rgba(255,255,255,0.09); border-radius:12px; padding:6px 14px; min-width:78px; text-align:center }
.value{font-size:22px;font-weight:800;font-variant-numeric:tabular-nums; color:#b8ff6b; text-shadow:0 0 12px rgba(122,255,110,.45)}
.best value color: #ffd166
Frame: position relative; border-radius 20px; padding:10px? Actually put canvas inside a padded container with border and glow.
.frame{ position:relative; border-radius:22px; padding:12px; background: linear-gradient(180deg, rgba(255,255,255,.06), rgba(255,255,255,.02)); border:1px solid rgba(255,255,255,.10); box-shadow: 0 30px 60px -20px rgba(0,0,0,.8), inset 0 1px 0 rgba(255,255,255,.08); }
canvas{width:100%;height:auto;display:block;border-radius:14px; background:#0a0f1c;}
Overlay: absolute inset 0 with pointer-events none; modal card pointer-events auto.
Banner: absolute top:16px; left:50%; transform translateX(-50%); pill.
Footer: hints row with kbd styling.
Responsive: at max-width 520px reduce paddings/fonts.
Let me write the score pop animation:
```
@keyframes pop{0%{transform:scale(1)}35%{transform:scale(1.25)}100%{transform:scale(1)}}
.value.pop{animation:pop .28s ease-out}
```
Apply by removing/adding class with reflow.
Now writing final code. Also add a "length" stat maybe; keep 3 stats: SCORE, BEST, LENGTH. On small screens maybe hide length. Fine.
Let me write it.
For the ripple effect: array of {x,y,r,maxR,life}.
Particles: {x,y,vx,vy,life,maxLife,size,color} positions in px.
burst(x,y): 16 particles, colors ['#ffd166','#ff6b81','#b8ff6b','#ffffff'].
Update: p.x+=p.vx*dt; p.vy+=240*dt; p.life-=dt.
Draw particles: alpha = life/maxLife; arc.
Also a flash ring: stroke circle radius grows, alpha fades.
Ok writing now.
One more: prevent default arrow scrolling: e.key === 'ArrowUp' etc. and space.
Also handle 'w','a','s','d' lowercase and uppercase.
Let me finalize the code.
For the canvas grid lines: draw lines every CELL with rgba(255,255,255,0.035), plus a checkerboard subtle? Maybe subtle alternating cell tint for texture: fill every other cell with rgba(255,255,255,0.015). Nice.
Let me now write everything out.
Potential issue: canvas height auto with aspect-ratio and JS setting width/height attributes. Since I set both to same value, intrinsic ratio 1:1 and CSS width:100% → height = width. Good.
ResizeObserver on canvas: when width changes, re-setup. But changing canvas.width doesn't change layout size (CSS-controlled), so no infinite loop. Use `new ResizeObserver(resize).observe(cv)`.
Careful: resize() sets cv.width which changes intrinsic size but CSS width:100% and height:auto → height derived from intrinsic ratio which stays 1:1 → no layout change. OK.
Write final answer.
Also: The demo banner should include "Press any key" but the requirement says "Click or press a key to start" message. I'll use exactly that phrasing: "Click or press a key to start playing" with a DEMO badge.
Let me write.
Final code below.
Also handle when player dies and restarts: reset to demo? No—restart as player.
Also: when the demo dies (AI trapped), auto-restart demo after 900ms, showing modal briefly? I'll show the modal with title "DEMO OVER" and score for 900ms then restart. Simpler: on demo death, just reset after 800ms with a toast in banner "Demo restarting…". Let's implement: state.over with demo flag → modal shown; setTimeout 900ms → if still in that state, reset(demo=true) and hide modal.
Hmm, but if the player has already started (demo=false), we don't auto restart.
OK writing.
One more detail: The overlay modal for game over shows score and best. Also "Play again" button.
Let me write the code now, final.
Regarding `step()` when dir change is queued: also handle the case where the queued direction would be a reverse of the *current* dir after applying previous queued: e.g., dir=right, queue=[up, down]? down isn't opposite of up? down = -up → yes opposite → rejected by the rule (last queued is up, down is opposite) ✓.
Good.
Code:
```js
const DIRS=[[1,0],[-1,0],[0,1],[0,-1]];
```
Now write.
Also I want the snake to look smooth even at low speed — interpolation handles it.
Let me write the final file.
I'll also add subtle grid "glow" under the snake? Not needed.
Final: ensure no console errors.
Writing now.
For particles I need dt in seconds: dt/1000.
Let me produce final code.