Nemotron 3.5 Super VL (free)
by NVIDIAReasoningClean
Running live in a sandboxed frame. Click inside it before using the keyboard.
How it went
- Total time
- 2 min 29 s
- Cost
- $0
- Output tokens
- 18,502
- Spent thinking
- 13,726 tokens
- Speed
- 125.1 tok/s
- Console errors
- 0
- First token after
- 955 ms
- First visible text after
- 2 min 1 s
- Prompt tokens
- 517
- Code size
- 12.7 KB
- Finish reason
- stop
- Served by
- Nvidia
- Model id
nvidia/nemotron-3.5-super-vl:free- Reasoning sent
model default- Max output
- 32,000 tokens
- Generated
- Nov 9, 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 · 490 lines
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Snake</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
background: #0b0c10;
color: #c5c6c7;
font-family: system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, sans-serif;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 100vh;
overflow: hidden;
}
.wrapper {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
width: 100%;
max-width: 600px;
padding: 20px;
}
.hud {
width: 100%;
max-width: 520px;
display: flex;
justify-content: space-between;
font-size: 1rem;
font-weight: 700;
letter-spacing: 0.4px;
}
.stat {
background: #12141c;
border: 1px solid #30323a;
padding: 6px 14px;
border-radius: 10px;
}
.stat b { color: #00e676; }
#container { position: relative; }
canvas {
background: #12141c;
border: 2px solid #30323a;
border-radius: 12px;
box-shadow: 0 0 80px rgba(0, 0, 0, 0.7);
display: block;
}
#overlay {
position: absolute;
inset: 0;
border-radius: 12px;
background: rgba(6, 7, 10, 0.9);
display: flex;
align-items: center;
justify-content: center;
z-index: 30;
cursor: pointer;
}
#overlay.hidden { display: none; }
.card {
text-align: center;
padding: 30px 24px;
border: 1px solid #30323a;
border-radius: 16px;
background: #12141c;
max-width: 86%;
}
.card h1 {
margin: 0 0 10px;
font-size: 2rem;
color: #00e676;
letter-spacing: 3px;
}
.card p { margin: 0 0 12px; line-height: 1.55; }
.card .hint {
margin-top: 14px;
font-size: 0.85rem;
color: #71727a;
}
@media (max-width: 480px) {
.wrapper { padding: 12px; }
.card { max-width: 92%; padding: 24px 18px; }
.card h1 { font-size: 1.6rem; }
}
</style>
</head>
<body>
<div class="wrapper">
<div class="hud">
<div class="stat">Score <b id="scoreEl">0</b></div>
<div class="stat">Best <b id="bestEl">0</b></div>
</div>
<div id="container">
<canvas id="game"></canvas>
<div id="overlay">
<div class="card">
<h1 id="ovTitle">SNAKE</h1>
<p id="ovText">Click or press a key to start</p>
<p class="hint" id="ovHint">Arrow keys / WASD to move • P to pause</p>
</div>
</div>
</div>
</div>
<script>
const canvas = document.getElementById('game');
const ctx = canvas.getContext('2d');
const scoreEl = document.getElementById('scoreEl');
const bestEl = document.getElementById('bestEl');
const overlay = document.getElementById('overlay');
const ovTitle = document.getElementById('ovTitle');
const ovText = document.getElementById('ovText');
const ovHint = document.getElementById('ovHint');
const COLS = 20;
const ROWS = 20;
let cellSize = 24;
let width = 0, height = 0;
let state = 'DEMO'; // DEMO | PLAYING | PAUSED | GAMEOVER
let score = 0;
let bestScore = 0;
let dir = { x: 1, y: 0 };
let turnQueue = [];
let snake = [];
let prevSnake = [];
let food = { x: 0, y: 0 };
let tickTimer = 0;
let tickRate = 130;
let renderT = 0;
let particles = [];
let lastTime = 0;
function lerp(a, b, t) { return a + (b - a) * t; }
function ease(t) { return t * t * (3 - 2 * t); }
function resize() {
const maxW = Math.min(window.innerWidth - 24, 520);
const maxH = window.innerHeight - 150;
cellSize = Math.floor(Math.min(maxW / COLS, maxH / ROWS, 32));
if (cellSize < 14) cellSize = 14;
width = COLS * cellSize;
height = ROWS * cellSize;
canvas.width = width;
canvas.height = height;
canvas.style.width = width + 'px';
canvas.style.height = height + 'px';
}
window.addEventListener('resize', resize);
function placeFood() {
let pos;
do {
pos = { x: Math.floor(Math.random() * COLS), y: Math.floor(Math.random() * ROWS) };
} while (snake.some(s => s.x === pos.x && s.y === pos.y));
food = pos;
}
function resetDemo() {
snake = [{ x: 10, y: 10 }, { x: 9, y: 10 }, { x: 8, y: 10 }];
prevSnake = snake.map(s => ({ x: s.x, y: s.y }));
dir = { x: 1, y: 0 };
turnQueue = [];
score = 0;
tickRate = 130;
placeFood();
}
function resetGame() {
resetDemo();
state = 'PLAYING';
tickTimer = 0;
renderT = 0;
hideOverlay();
}
function spawnParticles(cx, cy) {
for (let i = 0; i < 10; i++) {
const ang = Math.random() * Math.PI * 2;
const sp = 2 + Math.random() * 5;
particles.push({
x: cx, y: cy,
vx: Math.cos(ang) * sp,
vy: Math.sin(ang) * sp,
life: 0.35 + Math.random() * 0.35,
maxLife: 0.7,
color: `hsl(${45 + Math.random() * 50}, 100%, 65%)`
});
}
}
function updateParticles(dt) {
for (let i = particles.length - 1; i >= 0; i--) {
const p = particles[i];
p.x += p.vx;
p.y += p.vy;
p.vx *= 0.94;
p.vy *= 0.94;
p.life -= dt / 1000;
if (p.life <= 0) particles.splice(i, 1);
}
}
function roundRect(ctx, x, y, w, h, r) {
ctx.beginPath();
ctx.moveTo(x + r, y);
ctx.lineTo(x + w - r, y);
ctx.quadraticCurveTo(x + w, y, x + w, y + r);
ctx.lineTo(x + w, y + h - r);
ctx.quadraticCurveTo(x + w, y + h, x + w - r, y + h);
ctx.lineTo(x + r, y + h);
ctx.quadraticCurveTo(x, y + h, x, y + h - r);
ctx.lineTo(x, y + r);
ctx.quadraticCurveTo(x, y, x + r, y);
ctx.closePath();
ctx.fill();
}
function aiChoose() {
const dirs = [{ x: 0, y: -1 }, { x: 0, y: 1 }, { x: -1, y: 0 }, { x: 1, y: 0 }];
const cand = dirs.filter(d => !(d.x === -dir.x && d.y === -dir.y));
const safe = cand.filter(d => {
const hx = snake[0].x + d.x, hy = snake[0].y + d.y;
return hx >= 0 && hx < COLS && hy >= 0 && hy < ROWS &&
!snake.some(s => s.x === hx && s.y === hy);
});
const pick = safe.length ? safe : cand;
if (pick.length === 0) return dir;
pick.sort((a, b) => {
const ha = { x: snake[0].x + a.x, y: snake[0].y + a.y };
const hb = { x: snake[0].x + b.x, y: snake[0].y + b.y };
const da = Math.abs(ha.x - food.x) + Math.abs(ha.y - food.y);
const db = Math.abs(hb.x - food.x) + Math.abs(hb.y - food.y);
return da - db;
});
return pick[0];
}
function doTick() {
if (state === 'PLAYING' && turnQueue.length) {
const d = turnQueue.shift();
if (!(d.x === -dir.x && d.y === -dir.y)) dir = d;
}
const head = { x: snake[0].x + dir.x, y: snake[0].y + dir.y };
if (head.x < 0 || head.x >= COLS || head.y < 0 || head.y >= ROWS) {
gameOver();
return;
}
for (let i = 0; i < snake.length - 1; i++) {
if (head.x === snake[i].x && head.y === snake[i].y) {
gameOver();
return;
}
}
snake.unshift(head);
const ate = head.x === food.x && head.y === food.y;
if (ate) {
score += 10;
if (score > bestScore) bestScore = score;
spawnParticles(head.x * cellSize + cellSize / 2, head.y * cellSize + cellSize / 2);
placeFood();
tickRate = Math.max(60, 130 - Math.floor(score / 20) * 6);
} else {
snake.pop();
}
}
function gameOver() {
if (state === 'DEMO') {
resetDemo();
return;
}
state = 'GAMEOVER';
showOverlay('GAME OVER', `Score ${score} — Best ${bestScore}`, 'Press Space or Enter to restart');
}
function togglePause() {
if (state === 'PLAYING') {
state = 'PAUSED';
showOverlay('PAUSED', 'Press P to resume', '');
} else if (state === 'PAUSED') {
state = 'PLAYING';
hideOverlay();
lastTime = 0;
}
}
function startGame() {
if (state === 'PAUSED') {
state = 'PLAYING';
hideOverlay();
lastTime = 0;
return;
}
resetGame();
}
function showOverlay(title, text, hint) {
ovTitle.textContent = title;
ovText.innerHTML = text;
ovHint.textContent = hint;
overlay.classList.remove('hidden');
}
function hideOverlay() { overlay.classList.add('hidden'); }
function queueTurn(d) {
if (state !== 'PLAYING') return;
const ref = turnQueue.length ? turnQueue[turnQueue.length - 1] : dir;
if (d.x === -ref.x && d.y === -ref.y) return;
turnQueue.push(d);
}
window.addEventListener('keydown', e => {
const key = e.key;
if (['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', ' '].includes(key)) e.preventDefault();
if (state === 'DEMO' || state === 'GAMEOVER') {
if (key === ' ' || key === 'Enter') startGame();
return;
}
if (key === 'p' || key === 'P') { togglePause(); return; }
let d = null;
if (key === 'ArrowUp' || key === 'w' || key === 'W') d = { x: 0, y: -1 };
else if (key === 'ArrowDown' || key === 's' || key === 'S') d = { x: 0, y: 1 };
else if (key === 'ArrowLeft' || key === 'a' || key === 'A') d = { x: -1, y: 0 };
else if (key === 'ArrowRight' || key === 'd' || key === 'D') d = { x: 1, y: 0 };
if (d) queueTurn(d);
});
document.getElementById('container').addEventListener('pointerdown', () => {
if (state === 'DEMO' || state === 'GAMEOVER') startGame();
else if (state === 'PAUSED') { state = 'PLAYING'; hideOverlay(); lastTime = 0; }
});
function update(dt) {
updateParticles(dt);
if (state === 'PAUSED' || state === 'GAMEOVER') return;
tickTimer += dt;
if (tickTimer >= tickRate) {
tickTimer -= tickRate;
prevSnake = snake.map(s => ({ x: s.x, y: s.y }));
if (state === 'DEMO') dir = aiChoose();
doTick();
renderT = 0;
} else {
renderT = Math.min(1, tickTimer / tickRate);
}
}
function drawHead(x, y) {
const cx = x + cellSize / 2;
const cy = y + cellSize / 2;
const s = cellSize * 0.92;
const o = (cellSize - s) / 2;
ctx.fillStyle = '#00f299';
roundRect(ctx, x + o, y + o, s, s, 6);
const eyeR = cellSize * 0.13;
const eyeOff = cellSize * 0.22;
const pupOff = cellSize * 0.11;
let ex1, ey1, ex2, ey2, pux1, puy1, pux2, puy2;
if (dir.x === 1) {
ex1 = cx + eyeOff; ey1 = cy - eyeOff;
ex2 = cx + eyeOff; ey2 = cy + eyeOff;
pux1 = ex1 + pupOff; puy1 = ey1;
pux2 = ex2 + pupOff; puy2 = ey2;
} else if (dir.x === -1) {
ex1 = cx - eyeOff; ey1 = cy - eyeOff;
ex2 = cx - eyeOff; ey2 = cy + eyeOff;
pux1 = ex1 - pupOff; puy1 = ey1;
pux2 = ex2 - pupOff; puy2 = ey2;
} else if (dir.y === -1) {
ex1 = cx - eyeOff; ey1 = cy - eyeOff;
ex2 = cx + eyeOff; ey2 = cy - eyeOff;
pux1 = ex1; puy1 = ey1 - pupOff;
pux2 = ex2; puy2 = ey2 - pupOff;
} else {
ex1 = cx - eyeOff; ey1 = cy + eyeOff;
ex2 = cx + eyeOff; ey2 = cy + eyeOff;
pux1 = ex1; puy1 = ey1 + pupOff;
pux2 = ex2; puy2 = ey2 + pupOff;
}
ctx.fillStyle = '#ffffff';
ctx.beginPath(); ctx.arc(ex1, ey1, eyeR, 0, Math.PI * 2); ctx.fill();
ctx.beginPath(); ctx.arc(ex2, ey2, eyeR, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = '#0a0a0a';
ctx.beginPath(); ctx.arc(pux1, puy1, eyeR * 0.5, 0, Math.PI * 2); ctx.fill();
ctx.beginPath(); ctx.arc(pux2, puy2, eyeR * 0.5, 0, Math.PI * 2); ctx.fill();
}
function drawSnake() {
const t = ease(renderT);
for (let i = 0; i < snake.length; i++) {
const p0 = i < prevSnake.length ? prevSnake[i] : prevSnake[prevSnake.length - 1];
const p1 = snake[i];
const x = lerp(p0.x, p1.x, t) * cellSize;
const y = lerp(p0.y, p1.y, t) * cellSize;
if (i === 0) {
drawHead(x, y);
} else {
const isTail = i === snake.length - 1;
const s = cellSize * (isTail ? 0.88 : 0.9);
const o = (cellSize - s) / 2;
const alpha = 0.75 + 0.25 * (i / Math.max(1, snake.length - 1));
ctx.fillStyle = `rgba(0, 242, 153, ${alpha})`;
roundRect(ctx, x + o, y + o, s, s, isTail ? s / 2 : 4);
}
}
}
function drawFood() {
const x = food.x * cellSize;
const y = food.y * cellSize;
const pulse = 1 + Math.sin(Date.now() / 120) * 0.06;
const s = cellSize * 0.7 * pulse;
const o = (cellSize - s) / 2;
ctx.fillStyle = '#ff4d6d';
roundRect(ctx, x + o, y + o, s, s, 5);
ctx.fillStyle = 'rgba(255, 255, 255, 0.45)';
ctx.beginPath(); ctx.arc(x + o + s * 0.28, y + o + s * 0.28, s * 0.22, 0, Math.PI * 2); ctx.fill();
}
function drawParticles() {
for (const p of particles) {
ctx.globalAlpha = p.life / p.maxLife;
ctx.fillStyle = p.color;
ctx.beginPath(); ctx.arc(p.x, p.y, 3, 0, Math.PI * 2); ctx.fill();
}
ctx.globalAlpha = 1;
}
function drawGrid() {
ctx.strokeStyle = '#1c1f29';
ctx.lineWidth = 1;
ctx.beginPath();
for (let i = 0; i <= COLS; i++) {
ctx.moveTo(i * cellSize, 0);
ctx.lineTo(i * cellSize, height);
}
for (let i = 0; i <= ROWS; i++) {
ctx.moveTo(0, i * cellSize);
ctx.lineTo(width, i * cellSize);
}
ctx.stroke();
}
function draw() {
ctx.fillStyle = '#0b0c10';
ctx.fillRect(0, 0, width, height);
drawGrid();
drawFood();
drawParticles();
drawSnake();
}
function loop(ts) {
if (!lastTime) lastTime = ts;
let dt = ts - lastTime;
lastTime = ts;
if (dt > 180) dt = 180;
update(dt);
draw();
scoreEl.textContent = score;
bestEl.textContent = bestScore;
requestAnimationFrame(loop);
}
resize();
resetDemo();
requestAnimationFrame(loop);
</script>
</body>
</html>Reasoning trace (13,726 tokens)
The user wants a polished Snake game in a single HTML file. Let me break down the requirements:
1. Grid at least 20x20 cells, centered on page
2. Controls: Arrow keys and WASD. Quick successive presses honored (buffer next direction), no reverse onto itself.
3. Food: +10 points, grows by 1 cell, never spawns on snake.
4. Game over: wall or self collision, overlay with score, Space/Enter restarts, P pauses.
5. Visual polish: smooth movement (interpolation), distinct head with eyes facing direction, particle burst on eating.
6. UI: current score and best score of session.
7. Self-contained: inline CSS/JS, no external resources except allowed CDNs (but I'll avoid CDNs entirely to be safe).
8. Responsive: 1280x800 viewport, adapt to 375px, no horizontal scrollbar.
9. First paint meaningful: visible immediately.
10. Self-playing demo mode behind "Click or press a key to start" message (since it's a game).
11. Only receives keyboard after click.
12. Zero console errors.
Let me design the architecture:
Canvas rendering:
- Cell size: maybe 20-25px depending on screen. 20x20 grid = 400-500px canvas. Center it.
- Smooth movement: keep a `renderPos` for snake segments that lerps toward `gridPos` each frame. Or simpler: move snake on a fixed timestep (game tick), but render with interpolation between last and next position.
- Actually for Snake, smooth movement usually means the snake slides between cells. I can store each segment's current screen position and target grid position, lerp each frame.
Game loop:
- requestAnimationFrame
- Fixed timestep accumulator for game logic (e.g., 120ms per move initially, speed up slightly with score?)
- Input buffer: `nextDirection` and `queuedDirection`. On keydown, if valid (not opposite), set queued. On tick, apply queued if valid.
Snake:
- Array of segments: `{x, y}`.
- Head has eyes: draw eyes based on direction vector. For smooth movement, eyes should be on the head's render position.
Particles:
- Array of particles: `{x, y, vx, vy, life, color}`. Burst at food position when eaten.
Food:
- Random cell not on snake.
States:
- `MENU` (start screen with "Click or press a key to start", maybe demo AI playing)
- `PLAYING`
- `PAUSED`
- `GAMEOVER`
Demo AI:
- Simple AI: pathfind toward food? Or just random valid moves? The requirement says "self-playing demo mode (a simple AI plays) behind a click to start". I can implement a simple greedy AI that moves toward food, avoiding immediate death if possible. But since it's behind a start screen, I can just run the game loop in demo mode and render it.
Actually, simpler: in MENU state, run an AI snake that plays automatically. When user clicks/presses key, reset and start player game.
Best score: store in a variable (not localStorage per rules? Wait, rules say "Do not use localStorage..."). So best score is session-only, variable in JS.
Responsive:
- Canvas max width: maybe min(80vw, 600px). Center with flexbox.
- Handle resize.
Input handling:
- Prevent arrow key scrolling.
- Keydown event: set `inputBuffer`.
- Only process after click (focus canvas or container). I can add a click listener to start.
Let's write the code structure:
```html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Snake</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
background: #0b0c10;
color: #c5c6c7;
font-family: system-ui, -apple-system, sans-serif;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 100vh;
overflow: hidden; /* no scroll */
}
.wrap { display: flex; flex-direction: column; align-items: center; gap: 12px; width: 100%; max-width: 1280px; padding: 16px; }
canvas {
background: #1a1b22;
border: 2px solid #30323a;
border-radius: 8px;
box-shadow: 0 0 40px rgba(0,0,0,0.5);
display: block;
}
.hud {
display: flex;
gap: 24px;
font-size: 1.1rem;
font-weight: 600;
}
.stat { background: #1a1b22; padding: 6px 14px; border-radius: 6px; border: 1px solid #30323a; }
.overlay {
position: absolute? or fixed? Let's use fixed overlay covering canvas area or whole screen.
}
</style>
</head>
<body>
...
</body>
</html>
```
Let's think about overlay. I'll have a `<div id="overlay">` that is absolutely positioned over the canvas container. Since canvas is centered, I can make a container `position: relative` and overlay `position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; background: rgba(0,0,0,0.75); z-index: 10;`.
But body is flex column center. If I make container relative, overlay absolute covers it.
Grid: 20x20 cells. Cell size dynamic: `const COLS = 20, ROWS = 20;` Canvas size = COLS * cellSize. cellSize = clamp based on screen width. e.g. `cellSize = Math.min(24, Math.floor((window.innerWidth - 32) / COLS))` but ensure at least maybe 16. Canvas max width maybe 500px.
Game constants:
- TICK_BASE = 150ms (speed). Maybe speed up every 50 points: `tick = Math.max(70, 150 - Math.floor(score/50)*10)`.
Rendering smooth movement:
Approach: each snake segment stores `x, y` (grid) and `rx, ry` (render). On tick, compute new head, shift segments. For rendering, each frame lerp `rx` toward target `x` (which is grid position after tick). But since segments shift indices, it's easier to store `rx, ry` as the current interpolated position, and target positions as the grid path.
Simpler robust method:
- Keep `snake` array of `{x,y}`.
- Keep `renderSnake` array of `{x,y}` that lags behind.
- Each tick: compute new grid snake. Set `targetSnake` = new grid.
- Each frame: for each segment i, lerp `renderSnake[i]` toward `targetSnake[i]` by factor `min(1, dt / 0.08)`. But if snake grows, arrays differ length.
Alternative simpler: Since snake moves one cell per tick, and ticks are e.g. 100ms, we can store for each segment a `progress` from 0 to 1 between previous cell and current cell. But segment following is complex.
A simpler visual trick: draw snake segments at their grid positions, but offset the whole snake drawing by a fraction? No, body segments don't move simultaneously in a simple offset way.
Actually, for Snake, "smooth movement" usually means the head slides from cell to cell and body follows. We can achieve decent smoothness by:
- On each tick, record the previous grid positions (`prevSnake`) and current (`snake`).
- Render time `t` in [0,1] since last tick.
- For head: position = lerp(prev[0], curr[0], t).
- For body segment i: It should be somewhere between prev[i] and curr[i]? Actually body segment i moves into where segment i-1 was. So at time t, segment i is at lerp(prev[i], curr[i], t)? Let's check: at t=0, segment i is at prev[i]. At t=1, segment i should be at curr[i] (which equals prev[i-1] if no growth). Yes! Because curr[i] = prev[i-1] for i>0. So lerp(prev[i], curr[i], t) works perfectly for all segments when no growth.
- When growing: tail duplicates. Need to handle array length change. If we just keep arrays same length by pushing a duplicate tail at the end before tick? Let's handle: on tick without food, `curr = [newHead, ...prev.slice(0, -1)]`. With food: `curr = [newHead, ...prev]` (grows by 1). Then prev length vs curr length differs by 1.
- To keep it simple, we can pad prev with its last element to match curr length when growing. Or simpler: maintain `renderSegments` array independently, updating targets.
Given complexity, I'll implement:
- `snake`: current grid positions (updated on tick).
- `prevSnake`: grid positions before last tick.
- `t`: 0..1 interpolation factor reset to 0 on tick, increases by dt/tickRate.
- For rendering: for i in 0..snake.length-1:
- p0 = prevSnake[i] if i < prevSnake.length else prevSnake[prevSnake.length-1]
- p1 = snake[i]
- pos = lerp(p0, p1, ease(t))
- This looks smooth enough.
Ease function: `t => t < 0.5 ? 2*t*t : -1 + (4-2*t)*t` (smoothstep-ish) or just cubic. I'll use `t * t * (3 - 2 * t)` (smoothstep).
Head eyes:
- Determine head direction: from snake[1] to snake[0] (if length>1) else current direction vector.
- Draw two small white circles with black pupils offset according to direction.
- For smooth rendering, direction at render time: use snake[0] render pos and previous? We can compute facing based on `dir` (current movement vector).
Particles:
- On eat: create 8-12 particles at food center with random velocities, colors (food color / gold), life 0.5s. Update and draw each frame.
AI Demo:
- State `DEMO`: snake controlled by AI. Each tick, AI chooses direction: prefer toward food, avoid walls/self. Simple: check possible moves (up/down/left/right) excluding reverse, score them by Manhattan distance to food and safety (not colliding with body next tick), pick best. If none safe, pick any non-reverse.
- The demo runs in background behind menu overlay? Or the menu is overlay over the canvas. I can draw the demo game on canvas, and draw a semi-transparent overlay with "Click or press a key to start". That looks nice: game playing behind the start message.
States:
- `DEMO` initially: AI plays, overlay shows start message.
- `PLAYING`: user controls, no overlay except HUD.
- `PAUSED`: overlay "Paused", press P to resume.
- `GAMEOVER`: overlay "Game Over", score, best, press Space/Enter to restart.
Transitions:
- Click anywhere or keypress in DEMO -> reset to PLAYING (player starts with snake length 3, score 0).
- In PLAYING: P -> PAUSED. Space/Enter? maybe not restart until game over.
- In PAUSED: P -> PLAYING.
- In GAMEOVER: Space/Enter -> reset to PLAYING.
Input queue:
- `let nextDir = dir;` `let inputDir = null;`
- On keydown: map key to dir. If dir is not opposite to `dir`, set `inputDir = dir`. But to honor quick successive presses, we can buffer: on tick, if `inputDir` is valid relative to current `dir`, apply `dir = inputDir`, then `inputDir = null`. But what if user presses right then down quickly before tick? We need to keep the last valid intended direction. So `inputDir` holds the latest key. On tick: if inputDir and not opposite(dir), dir = inputDir; inputDir = null. That handles quick presses: only last one before tick matters, but if tick is fast enough, two presses between ticks could be lost. To honor "both" quick successive presses, we can use a queue array: `dirQueue`. On keydown push valid dirs. On tick, shift from queue and apply if valid. But typical snake games just buffer one turn ahead. Requirement: "Quick successive key presses must both be honored". This means if I press Right then Down quickly (between ticks), the snake should turn Right then Down (two turns) rather than ignoring one. This requires a queue of pending turns.
Implementation:
- `let turnQueue = [];`
- On keydown: candidate dir. If queue empty, check opposite to current `dir`; if not opposite, push. If queue not empty, check opposite to queue[last]; if not opposite, push. (You can queue multiple turns.)
- On tick: if turnQueue.length > 0: `dir = turnQueue.shift();` But wait, if we shift and apply immediately on this tick, that's one turn per tick max. If user pressed two keys between ticks, both get applied on consecutive ticks. Good.
- Edge: if user presses opposite of current, ignore. If presses opposite of last queued, ignore.
Direction opposite check: `(d.x === -dir.x && d.y === -dir.y)`.
Food spawn: random cell not in snake.
Grid lines: draw subtle grid.
Colors:
- Background: #0f1117
- Grid: #1c1e26
- Snake body: #00d26a (green), head: #0ff (cyan) or brighter green.
- Food: #ff4757 (red) or gold #feca57.
- Eyes: white with black pupils.
Canvas rendering details:
- Clear canvas.
- Draw grid cells.
- Draw food (pulse maybe).
- Draw particles.
- Draw snake: for each segment, rounded rect. Head distinct: maybe slightly larger, with eyes.
- Eyes: compute head facing direction. Use `dir` for facing. But during render interpolation, `dir` is current intended direction. Good enough.
- Eye positions: offset from head center based on direction. E.g., if facing right: eyes at head center + (-4,-4) and (-4,4) relative? Actually eyes should be on the side the head is facing? In typical snake, eyes are on the head looking forward. Let's place eyes slightly ahead of center? Or just on the head with pupils offset toward facing direction. Simpler: draw two eyes on the head, pupils offset in facing direction. That shows facing direction via pupil offset. But requirement says "eyes facing its direction". Pupil offset is fine. Or draw eyes on the leading edge. Let's do: head circle/rounded rect, two eye whites positioned per direction, pupils offset toward facing direction.
Actually, distinct head with eyes facing direction: I'll draw head as a circle/rounded rect at head render pos. Two small circles (eyes) placed based on direction (e.g., facing right: eyes at left side of head? No, eyes usually at front). Let's place eyes near the front edge: facing right -> eyes at center + (4, -3) and center + (4, 3), pupils further right. Facing up -> eyes at (-3, -4) and (3, -4), pupils up. That clearly shows facing.
Score HUD: top or bottom? Let's place HUD above canvas.
Best score: session variable `bestScore`.
Game over check:
- Head x < 0 or >= COLS or y < 0 or >= ROWS.
- Head collides with body (any segment index > 0). Note: due to smooth render, collision logic uses grid positions.
Speed up: `tickRate = Math.max(60, 150 - Math.floor(score / 20) * 5)` maybe.
Pause: stop game loop logic but keep rendering? Or just freeze. I'll keep rendering last frame but stop ticks. Overlay shows Paused.
First paint: on load, DEMO state runs immediately, canvas shows snake moving, overlay says start. So screenshot sees action.
Responsive:
- Container centered. Canvas size based on cell size.
- Use CSS: `max-width: 90vw; max-height: 70vh;` on canvas? But canvas internal resolution should match CSS size for crispness? We can set canvas width/height via JS and CSS width/height 100%. Better: set canvas width/height to COLS*cellSize, and CSS width/height to same px. Then container centers.
- On resize, recalculate cellSize and resize canvas.
No horizontal scrollbar: body overflow hidden, container fits.
No external files. No localStorage. No alert.
Let's write code.
Variables:
```js
const COLS = 20;
const ROWS = 20;
let cellSize = 24;
let canvas, ctx;
let width, height;
let state = 'DEMO'; // DEMO, PLAYING, PAUSED, GAMEOVER
let score = 0;
let bestScore = 0;
let dir = {x:1, y:0};
let nextDir = {x:1, y:0};
let turnQueue = [];
let snake = [];
let prevSnake = [];
let food = {x:5, y:5};
let tickTimer = 0;
let tickRate = 120;
let renderT = 0; // 0..1
let particles = [];
let demoAi = ...;
let lastTime = 0;
```
Init:
```js
function resetGame() {
snake = [{x:10,y:10},{x:9,y:10},{x:8,y:10}];
prevSnake = snake.slice();
dir = {x:1,y:0};
turnQueue = [];
score = 0;
tickRate = 120;
placeFood();
state = 'PLAYING';
renderT = 0;
tickTimer = 0;
}
function placeFood() {
let pos;
do {
pos = {x: Math.floor(Math.random()*COLS), y: Math.floor(Math.random()*ROWS)};
} while (snake.some(s => s.x===pos.x && s.y===pos.y));
food = pos;
}
```
Input:
```js
window.addEventListener('keydown', e => {
if (['ArrowUp','ArrowDown','ArrowLeft','ArrowRight',' '].includes(e.key)) e.preventDefault();
let d = null;
if (e.key === 'ArrowUp' || e.key === 'w') d = {x:0,y:-1};
else if (e.key === 'ArrowDown' || e.key === 's') d = {x:0,y:1};
else if (e.key === 'ArrowLeft' || e.key === 'a') d = {x:-1,y:0};
else if (e.key === 'ArrowRight' || e.key === 'd') d = {x:1,y:0};
else if (e.key === ' ') handleSpace();
else if (e.key === 'Enter') handleEnter();
else if (e.key.toLowerCase() === 'p') togglePause();
if (d) queueTurn(d);
});
```
But keydown for Space/Enter/P should work regardless of state maybe. Let's unify:
- If state === DEMO: any key (except maybe arrow? arrows queue for demo? ignore) -> start. Better: click or any key starts. But arrow keys in demo could be confusing. I'll treat any key press in DEMO as start request.
- If PLAYING: arrow/WASD queue turn. P pauses.
- If PAUSED: P resumes.
- If GAMEOVER: Space/Enter restarts.
Also need click/touch to start: `container.addEventListener('mousedown', ...)` and maybe `pointerdown`. This also focuses.
Queue turn validation:
```js
function queueTurn(d) {
if (state !== 'PLAYING') return;
let ref = turnQueue.length ? turnQueue[turnQueue.length-1] : dir;
if (d.x === -ref.x && d.y === -ref.y) return;
turnQueue.push(d);
}
```
Game tick (called with dt):
```js
function update(dt) {
if (state === 'PAUSED' || state === 'GAMEOVER') return;
// particles
updateParticles(dt);
if (state === 'DEMO') {
demoAiTick();
tickTimer += dt;
if (tickTimer >= tickRate) {
tickTimer %= tickRate; // or subtract
doTick();
}
renderT = 0; // demo render can just snap or interpolate; let's interpolate
renderT = Math.min(1, tickTimer / tickRate); // hmm need prevSnake updated
return;
}
// PLAYING
tickTimer += dt;
if (tickTimer >= tickRate) {
tickTimer -= tickRate;
prevSnake = snake.map(s => ({x:s.x, y:s.y}));
doTick();
renderT = 0;
} else {
renderT = Math.min(1, tickTimer / tickRate);
}
}
```
Wait, for smooth interpolation we need prevSnake before tick and snake after tick. `doTick()` mutates snake. So in tick block: save prev, doTick, renderT=0. Between ticks renderT grows.
`doTick()`:
```js
function doTick() {
if (turnQueue.length) {
let d = turnQueue.shift();
// double check opposite with current dir (should be valid already but safe)
if (!(d.x === -dir.x && d.y === -dir.y)) dir = d;
}
let head = {x: snake[0].x + dir.x, y: snake[0].y + dir.y};
// wall collision
if (head.x < 0 || head.x >= COLS || head.y < 0 || head.y >= ROWS) { gameOver(); return; }
// self collision (head into body). Allow tail move? Standard: check all body except tail if moving? Just check all except maybe last if tail moves. Simpler: check all body segments.
for (let i=0; i<snake.length; i++) {
if (head.x === snake[i].x && head.y === snake[i].y) { gameOver(); return; }
}
snake.unshift(head);
let ate = head.x === food.x && head.y === food.y;
if (ate) {
score += 10;
if (score > bestScore) bestScore = score;
spawnParticles(head.x * cellSize + cellSize/2, head.y * cellSize + cellSize/2);
placeFood();
// speed up
tickRate = Math.max(60, 120 - Math.floor(score/20)*4);
} else {
snake.pop();
}
renderT = 0;
}
```
Wait, if we `unshift` and then `pop`, length stays same. `prevSnake` captured before this. Good.
AI Demo:
```js
function demoAiTick() {
// if it's time to tick, choose direction
// We can choose direction every tick.
// We need current dir to avoid reverse.
let candidates = [{x:0,y:-1},{x:0,y:1},{x:-1,y:0},{x:1,y:0}];
candidates = candidates.filter(d => !(d.x === -dir.x && d.y === -dir.y));
// score candidates: prefer closer to food, avoid immediate body/wall
let best = null, bestScoreVal = -Infinity;
for (let d of candidates) {
let hx = snake[0].x + d.x, hy = snake[0].y + d.y;
if (hx<0||hx>=COLS||hy<0||hy>=ROWS) continue;
if (snake.some(s=>s.x===hx&&s.y===hy)) continue;
let dist = Math.abs(hx-food.x)+Math.abs(hy-food.y);
let scoreVal = -dist * 10 + Math.random(); // random to break ties
if (scoreVal > bestScoreVal) { bestScoreVal = scoreVal; best = d; }
}
if (!best) {
// pick any non reverse even if collides? game over demo then restart
best = candidates[0] || {x:1,y:0};
}
// queue it
turnQueue = []; // clear? AI sets direct dir basically
turnQueue.push(best);
}
```
Since demo tick rate is same, we can just set `dir = best` each tick without queue. But queue works too. Let's just set dir directly in AI to avoid queue complexities in demo. I'll make `doTick` use turnQueue if playing, but demo can set dir directly before calling doTick.
Simplify: `update()` for demo: on tick, `dir = aiChoose(); doTick();`. AI choose returns valid non-reverse direction.
Particles:
```js
function spawnParticles(cx, cy) {
for (let i=0;i<10;i++) {
let ang = Math.random()*Math.PI*2;
let sp = 2 + Math.random()*4;
particles.push({
x: cx, y: cy,
vx: Math.cos(ang)*sp,
vy: Math.sin(ang)*sp,
life: 0.4 + Math.random()*0.4,
maxLife: 0.8,
color: `hsl(${40 + Math.random()*60}, 100%, 60%)`
});
}
}
function updateParticles(dt) {
for (let i=particles.length-1;i>=0;i--) {
let p = particles[i];
p.x += p.vx; p.y += p.vy;
p.life -= dt/1000;
if (p.life <= 0) particles.splice(i,1);
}
}
```
Rendering:
```js
function draw() {
// clear
ctx.fillStyle = '#0b0c10';
ctx.fillRect(0,0,width,height);
// grid
ctx.strokeStyle = '#16181f';
ctx.lineWidth = 1;
for (let i=0;i<=COLS;i++) { ctx.beginPath(); ctx.moveTo(i*cellSize,0); ctx.lineTo(i*cellSize,height); ctx.stroke(); }
for (let i=0;i<=ROWS;i++) { ctx.beginPath(); ctx.moveTo(0,i*cellSize); ctx.lineTo(width,i*cellSize); ctx.stroke(); }
// food
drawFood();
// particles
drawParticles();
// snake
drawSnake();
// overlay handled by HTML/CSS
}
```
`drawSnake`:
```js
function drawSnake() {
for (let i=0;i<snake.length;i++) {
let p0 = (i < prevSnake.length) ? prevSnake[i] : prevSnake[prevSnake.length-1];
let p1 = snake[i];
let x = lerp(p0.x, p1.x, renderT) * cellSize;
let y = lerp(p0.y, p1.y, renderT) * cellSize;
let isHead = i === 0;
if (isHead) drawHead(x, y);
else drawBody(x, y, i === snake.length-1);
}
}
```
`drawBody`: rounded rect with green gradient maybe. Tail slightly rounded.
`drawHead(x,y)`:
- `cx = x + cellSize/2`, `cy = y + cellSize/2`.
- Draw head fill: brighter green, rounded rect.
- Eyes based on `dir`:
```js
function drawHead(cx, cy) {
ctx.fillStyle = '#00ff88';
roundRect(ctx, x, y, cellSize, cellSize, 4); // need x,y though
}
```
Let me pass x,y.
```js
function drawHead(x, y) {
const cx = x + cellSize/2;
const cy = y + cellSize/2;
const s = cellSize * 0.9;
ctx.fillStyle = '#00e676';
roundRect(ctx, x + cellSize*0.05, y + cellSize*0.05, cellSize*0.9, cellSize*0.9, 6);
// eyes
ctx.fillStyle = '#fff';
let eyeR = cellSize * 0.12;
let eyeOffset = cellSize * 0.25;
let pupilOffset = cellSize * 0.12;
let ex1, ey1, ex2, ey2, pux1, puy1, pux2, puy2;
if (dir.x === 1) { // right
ex1 = cx + eyeOffset; ey1 = cy - eyeOffset;
ex2 = cx + eyeOffset; ey2 = cy + eyeOffset;
pux1 = ex1 + pupilOffset; puy1 = ey1;
pux2 = ex2 + pupilOffset; puy2 = ey2;
} else if (dir.x === -1) {
ex1 = cx - eyeOffset; ey1 = cy - eyeOffset;
ex2 = cx - eyeOffset; ey2 = cy + eyeOffset;
pux1 = ex1 - pupilOffset; puy1 = ey1;
pux2 = ex2 - pupilOffset; puy2 = ey2;
} else if (dir.y === -1) { // up
ex1 = cx - eyeOffset; ey1 = cy - eyeOffset;
ex2 = cx + eyeOffset; ey2 = cy - eyeOffset;
pux1 = ex1; puy1 = ey1 - pupilOffset;
pux2 = ex2; puy2 = ey2 - pupilOffset;
} else { // down
ex1 = cx - eyeOffset; ey1 = cy + eyeOffset;
ex2 = cx + eyeOffset; ey2 = cy + eyeOffset;
pux1 = ex1; puy1 = ey1 + pupilOffset;
pux2 = ex2; puy2 = ey2 + pupilOffset;
}
ctx.beginPath(); ctx.arc(ex1, ey1, eyeR, 0, Math.PI*2); ctx.fill();
ctx.beginPath(); ctx.arc(ex2, ey2, eyeR, 0, Math.PI*2); ctx.fill();
ctx.fillStyle = '#000';
ctx.beginPath(); ctx.arc(pux1, puy1, eyeR*0.5, 0, Math.PI*2); ctx.fill();
ctx.beginPath(); ctx.arc(pux2, puy2, eyeR*0.5, 0, Math.PI*2); ctx.fill();
}
```
Note: head render position during interpolation uses `dir` for eyes. Since `dir` changes instantly on tick but head renders between prev and current, eyes might look like they snap. Acceptable.
Food draw:
```js
function drawFood() {
const x = food.x * cellSize;
const y = food.y * cellSize;
const s = cellSize * 0.7;
const offset = (cellSize - s)/2;
// pulse
const pulse = 1 + Math.sin(Date.now()/100) * 0.05;
ctx.fillStyle = '#ff4757';
roundRect(ctx, x+offset, y+offset, s*pulse, s*pulse, 4);
// shine
ctx.fillStyle = 'rgba(255,255,255,0.4)';
ctx.beginPath(); ctx.arc(x+offset+s*0.3, y+offset+s*0.3, s*0.2, 0, Math.PI*2); ctx.fill();
}
```
Particles draw:
```js
function drawParticles() {
for (let p of particles) {
ctx.globalAlpha = p.life / p.maxLife;
ctx.fillStyle = p.color;
ctx.beginPath(); ctx.arc(p.x, p.y, 3, 0, Math.PI*2); ctx.fill();
ctx.globalAlpha = 1;
}
}
```
Round rect helper:
```js
function roundRect(ctx, x, y, w, h, r) {
ctx.beginPath();
ctx.roundRect? // not supported everywhere? Actually modern Chrome supports ctx.roundRect. But to be safe use path.
ctx.moveTo(x+r, y);
ctx.lineTo(x+w-r, y);
ctx.quadraticCurveTo(x+w, y, x+w, y+r);
ctx.lineTo(x+w, y+h-r);
ctx.quadraticCurveTo(x+w, y+h, x+w-r, y+h);
ctx.lineTo(x+r, y+h);
ctx.quadraticCurveTo(x, y+h, x, y+h-r);
ctx.lineTo(x, y+r);
ctx.quadraticCurveTo(x, y, x+r, y);
ctx.closePath();
ctx.fill();
}
```
Loop:
```js
function loop(timestamp) {
if (!lastTime) lastTime = timestamp;
let dt = timestamp - lastTime;
lastTime = timestamp;
if (dt > 200) dt = 200; // tab switch catch-up
update(dt);
draw();
requestAnimationFrame(loop);
}
requestAnimationFrame(loop);
```
Overlay HTML:
```html
<div class="container" id="container">
<div class="hud">
<div class="stat">Score <span id="scoreEl">0</span></div>
<div class="stat">Best <span id="bestEl">0</span></div>
</div>
<canvas id="game"></canvas>
<div id="overlay">
<div class="overlay-card">
<h1 id="overlayTitle">Snake</h1>
<p id="overlayText">Click or press a key to start</p>
<div class="hints">Arrow keys / WASD to move • P to pause</div>
</div>
</div>
</div>
```
Overlay styles: absolute, center, hidden when playing.
```css
#container { position: relative; }
#overlay {
position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center;
background: rgba(6,7,10,0.85); z-index: 20; border-radius: 8px; pointer-events: auto;
cursor: pointer;
}
.overlay-card { text-align: center; padding: 24px; border: 1px solid #30323a; border-radius: 12px; background: #12141c; }
.overlay-card h1 { color: #00e676; margin-bottom: 8px; font-size: 1.8rem; }
#overlayText { margin-bottom: 12px; color: #c5c6c7; }
.hints { font-size: 0.9rem; color: #71727a; }
.hidden { display: none !important; }
```
Game over overlay content dynamically changed. Maybe keep one overlay and update title/text. Add restart hint when game over.
State management for overlay:
```js
function setOverlay(show, title, text, hint) {
const overlay = document.getElementById('overlay');
const titleEl = document.getElementById('overlayTitle');
const textEl = document.getElementById('overlayText');
if (show) {
overlay.classList.remove('hidden');
titleEl.textContent = title;
textEl.textContent = text;
// hint optional
} else {
overlay.classList.add('hidden');
}
}
```
But for GAMEOVER we want a card with score and restart hint. Could have separate inner HTML. Simpler: overlay card always there, we update its contents.
Start screen: title "SNAKE", text "Click or press a key to start", hints below.
Paused: title "PAUSED", text "Press P to resume".
Game Over: title "GAME OVER", text `Final Score: ${score} Best: ${bestScore}`, hint "Press Space or Enter to restart".
HUD update every frame or on score change.
Input start:
```js
function startGame() {
if (state === 'PAUSED') { state = 'PLAYING'; setOverlay(false); return; }
resetGame();
setOverlay(false);
}
window.addEventListener('keydown', e => {
if (state === 'DEMO' || state === 'GAMEOVER') {
if (e.key === ' ' || e.key === 'Enter') { e.preventDefault(); startGame(); }
return;
}
// ... normal input
});
container.addEventListener('pointerdown', () => {
if (state === 'DEMO' || state === 'GAMEOVER') startGame();
else if (state === 'PAUSED') { state='PLAYING'; setOverlay(false); }
// else just focus?
});
```
But requirement: "Click or press a key to start" message. Any key in DEMO starts? Arrows in demo would start, which is fine. But if I call startGame on any key in DEMO, then arrow keys start game and also queue a turn? That's okay, game starts immediately. Let's allow any key in DEMO to start.
In PLAYING, arrows/WASD queue turn, P pauses.
In PAUSED, P resumes, maybe Space/Enter too.
In GAMEOVER, Space/Enter restart.
`togglePause`:
```js
function togglePause() {
if (state === 'PLAYING') { state='PAUSED'; setOverlay(true, 'PAUSED', 'Press P to resume'); }
else if (state === 'PAUSED') { state='PLAYING'; setOverlay(false); lastTime=0; } // reset dt
}
```
Game over:
```js
function gameOver() {
state = 'GAMEOVER';
setOverlay(true, 'GAME OVER', `Score: ${score} Best: ${bestScore}`, 'Press Space or Enter to restart');
}
```
Make sure `setOverlay` can accept hint and show it. I'll make overlay inner HTML dynamic:
Actually simpler: keep overlay card structure with title, text, hint. For game over, combine score into text.
Best score update: when score > best, update.
Demo mode details: Demo should run from load. So initial state DEMO, snake initialized, food placed. Overlay visible. Loop runs. AI ticks.
Demo snake init same as player.
`aiChooseDirection`:
```js
function aiChoose() {
let cand = [{x:0,y:-1},{x:0,y:1},{x:-1,y:0},{x:1,y:0}].filter(d => !(d.x===-dir.x && d.y===-dir.y));
let valid = cand.filter(d => {
let hx = snake[0].x + d.x, hy = snake[0].y + d.y;
return hx>=0 && hx<COLS && hy>=0 && hy<ROWS && !snake.some(s=>s.x===hx&&s.y===hy);
});
if (valid.length === 0) return cand[0] || dir; // forced collision -> game over demo restarts? Let's just pick dir and accept game over, then auto restart demo.
// prefer toward food
valid.sort((a,b) => {
let ha = {x:snake[0].x+a.x, y:snake[0].y+a.y};
let hb = {x:snake[0].x+b.x, y:snake[0].y+b.y};
let da = Math.abs(ha.x-food.x)+Math.abs(ha.y-food.y);
let db = Math.abs(hb.x-food.x)+Math.abs(hb.y-food.y);
return da - db;
});
return valid[0];
}
```
Demo `doTick` uses `dir = aiChoose()` then proceeds. Since demo might die, `gameOver()` sets state GAMEOVER, overlay says Demo over? Maybe just restart demo automatically? But requirement says self-playing demo behind click to start. If demo dies, we can auto restart demo after a moment or just leave game over overlay saying "Demo ended, click to start". Simpler: on demo game over, reset demo state and continue (auto restart). But overlay still says click to start? That might be weird. I'll just let it reset silently and continue demo with overlay still visible.
Actually if state becomes GAMEOVER from demo, overlay shows GAMEOVER and says click to restart, which also starts player game. That's fine. But then demo ended. I'd rather auto-respawn demo: in `gameOver`, if state was DEMO before, just call `resetDemo()` and keep state DEMO. Let's handle: `gameOver()` checks `if (state === 'DEMO') { resetGame(); return; }` because resetGame sets state PLAYING... I need `resetDemo()` that keeps state DEMO.
```js
function resetDemo() {
snake = [{x:10,y:10},{x:9,y:10},{x:8,y:10}];
prevSnake = snake.slice();
dir = {x:1,y:0};
score = 0;
tickRate = 120;
placeFood();
}
function resetGame() {
resetDemo();
state = 'PLAYING';
}
```
And `gameOver()`:
```js
function gameOver() {
if (state === 'DEMO') { resetDemo(); return; }
state = 'GAMEOVER';
setOverlay(true, ...);
}
```
Responsive canvas sizing:
```js
function resize() {
const container = document.getElementById('container');
const maxW = Math.min(window.innerWidth - 32, 520); // cap
const maxH = window.innerHeight - 160; // room for hud
cellSize = Math.floor(Math.min(maxW / COLS, maxH / ROWS, 30));
if (cellSize < 16) cellSize = 16;
width = COLS * cellSize;
height = ROWS * cellSize;
canvas.width = width;
canvas.height = height;
canvas.style.width = width + 'px';
canvas.style.height = height + 'px';
}
window.addEventListener('resize', resize);
```
Call resize on load.
On small screens (375px), innerWidth-32 = 343, /20 = 17. So cellSize 17. Canvas 340x340. Fits. Good.
Ensure no horizontal scroll: body overflow hidden. Container flexible.
Keyboard blocking arrow scroll: preventDefault on arrows.
Now HUD: scoreEl, bestEl update in `draw` or whenever score changes. I'll update in `doTick` when score changes, and also each frame set text.
Let's write the full HTML.
Potential issue: `ctx.roundRect` not used, I'll implement manual rounded rect.
Double-check `prevSnake` handling when snake grows:
- Before tick: prevSnake = snake (length N).
- After eat: snake = [head, ...snake] (length N+1). prevSnake length N.
- In render loop for i=N (new tail): p0 = prevSnake[prevSnake.length-1] = old tail. p1 = snake[N] = old tail (since we didn't pop, new tail is old tail at same position? Actually snake = [head, ...oldSnake], so snake[N] = oldSnake[N-1] = old tail. So p1 == p0. Lerp stays still. Good. The new segment appears at old tail and stays there next tick as body shifts. Smooth enough.
Edge: `renderT` reset to 0 on tick. Between ticks it increases. At exactly tick moment, renderT=0, segments at prev positions. Then they lerp toward new positions. Looks smooth.
One issue: `turnQueue` handling. If user presses two keys rapidly between ticks:
- Keydown Right: queue [Right] (dir is Up maybe, valid).
- Keydown Down: ref = queue[last] = Right. Down not opposite Right. queue [Right, Down].
- Tick: dir = Right. queue [Down].
- Next tick: dir = Down.
This honors both presses. Good.
But if first press is opposite of current dir (ignored), second press relative to current dir? My queueTurn checks ref = queue last or dir. If queue empty and press opposite dir, ignore. If then press perpendicular, queue [perp]. Good.
When `doTick` shifts, it sets `dir = d` without re-checking opposite? It was checked at enqueue time relative to ref, but ref might be old if multiple queued. Example: dir=Up, queue [Right, Down]. Right is valid vs Up. Down is valid vs Right. Shift Right -> dir=Right. Next tick shift Down -> dir=Down. Down is opposite Up? No, current dir is Right, Down is perpendicular. Fine.
Example: dir=Up, queue [Right, Left] (Left opposite Right? Left is opposite Right, so enqueue Left would be ignored because ref=Right and Left is opposite. So can't happen).
What if dir=Up, queue [Right]. Before tick, user presses Left. ref=Right, Left is opposite Right, ignored. Good.
Edge: after shifting Right on tick, `dir=Right`, queue empty. Next tick user had pressed Down earlier but it was ignored because at enqueue time ref was Right. That's correct behavior (can't reverse from Right to Left, but Down is fine). Wait, Down vs Right is fine. But if user pressed Left while queued Right, ignored because Left opposite Right. Correct.
All good.
Now, `update(dt)`: I need to manage `tickTimer` and `renderT` separately for demo vs playing. Demo doesn't need input queue but uses same `doTick`. I'll unify:
```js
function update(dt) {
updateParticles(dt);
if (state === 'PAUSED' || state === 'GAMEOVER') return;
tickTimer += dt;
if (tickTimer >= tickRate) {
tickTimer -= tickRate;
prevSnake = snake.map(s => ({x:s.x, y:s.y}));
if (state === 'DEMO') {
dir = aiChoose();
}
doTick();
renderT = 0;
} else {
renderT = Math.min(1, tickTimer / tickRate);
}
}
```
`doTick` uses `turnQueue` if playing:
```js
function doTick() {
if (state === 'PLAYING' && turnQueue.length) {
let d = turnQueue.shift();
if (!(d.x === -dir.x && d.y === -dir.y)) dir = d;
}
// ... movement & collision
}
```
In demo, `dir` already set by `aiChoose()` before `doTick`, and turnQueue empty so no conflict.
Collision check against body: when moving into tail cell that is about to vacate, standard snake allows it. My check checks all body segments including tail. If tail is moving, head can move into tail's current cell? In grid snake, if length > 1 and moving forward, tail moves away same tick, so head entering tail's old cell is safe. But my check happens before pop, so snake still includes tail. If head.x == tail.x and head.y == tail.y, I'd trigger game over incorrectly. To fix, exclude the tail segment from collision check when not growing? But we don't know if growing yet (food check after). Standard approach: check collision with body except the last segment (tail), because tail will move. However if eating food, tail doesn't move, but head wouldn't be on tail anyway unless length=1. So:
```js
let selfHit = false;
for (let i=0; i < snake.length - (ate ? 0 : 1); i++) ...
```
But we compute `ate` after collision. Simpler: check all segments except the last one:
```js
for (let i=0; i < snake.length - 1; i++) {
if (head.x === snake[i].x && head.y === snake[i].y) { gameOver(); return; }
}
```
This allows moving into tail cell. Good enough.
But when length is 1? Snake won't be length 1. Start length 3.
Now demo AI might box itself and die, auto restart. Fine.
Visual polish: draw grid with subtle lines. Add a slight glow? Keep simple but clean.
CSS variables for colors? Fine.
Let's assemble final HTML. Ensure no console errors: all variables defined, event listeners attached after DOM content or script at bottom.
Script at bottom or `DOMContentLoaded`. I'll put script at end of body.
No `alert`, `localStorage`, etc.
Prevent default on arrow keys:
```js
window.addEventListener('keydown', e => {
if (['ArrowUp','ArrowDown','ArrowLeft','ArrowRight',' '].includes(e.key)) e.preventDefault();
...
});
```
But preventDefault on space in GAMEOVER might prevent scroll, okay.
Click to start: pointerdown on container. Also maybe `touchstart`. pointerdown covers touch.
Overlay when game over: pointerdown on overlay calls startGame. But if game over and user clicks, restart.
Hints: show controls always under overlay? Could be part of overlay card.
Let's make overlay card dynamic content via `innerHTML` to simplify:
```js
function showOverlay(html) {
const overlay = document.getElementById('overlay');
overlay.innerHTML = html;
overlay.classList.remove('hidden');
}
function hideOverlay() {
document.getElementById('overlay').classList.add('hidden');
}
```
But need click handler on overlay. If I use innerHTML with a button, button click might be tricky. Simpler: overlay has a fixed structure, we just change text.
```html
<div id="overlay" class="hidden">
<div class="card">
<h1 id="ov-title">SNAKE</h1>
<p id="ov-text">Click or press a key to start</p>
<p id="ov-hint" style="margin-top:10px; font-size:.9rem; color:#71727a">Arrow keys / WASD • P to pause</p>
</div>
</div>
```
`setOverlay(title, text, hint)`.
When game over: title "GAME OVER", text `Score ${score} — Best ${bestScore}`, hint "Press Space or Enter to restart".
Clicking overlay in any state:
- DEMO or GAMEOVER: startGame.
- PAUSED: resume.
- PLAYING: maybe nothing.
Add `pointerdown` on overlay to trigger.
But if PLAYING and user clicks canvas, nothing (game continues). Good.
Now, `setOverlay(false)` hides.
`startGame`:
```js
function startGame() {
if (state === 'PAUSED') { state = 'PLAYING'; hideOverlay(); lastTime = 0; return; }
resetGame();
hideOverlay();
}
```
`resetGame` sets state PLAYING. Good.
Initial: state DEMO. `resetDemo()` called at init. Overlay shown.
Now code `aiChoose()` uses `snake`, `food`, `dir`. Demo `dir` updates each tick. Good.
Make sure `turnQueue` is cleared on reset.
Particles color: use food color-ish.
Let's write the code now. I'll include comments.
One thing: `ctx` on canvas. `canvas.width/height` set via resize. CSS also sets width/height to same so no scaling blur.
Font: system-ui.
HUD positioned absolutely above canvas? Let's put HUD above canvas in normal flow.
Body layout:
```css
body {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 100vh;
overflow: hidden;
background: #0b0c10;
color: #c5c6c7;
font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
}
.wrapper { display: flex; flex-direction: column; align-items: center; gap: 10px; width: 100%; max-width: 600px; padding: 20px; box-sizing: border-box; }
.hud { width: 100%; max-width: 520px; display: flex; justify-content: space-between; font-size: 1rem; font-weight: 600; }
.stat { background:#12141c; border:1px solid #30323a; padding:6px 12px; border-radius:8px; }
.stat b { color:#00e676; }
#container { position: relative; }
canvas { background:#12141c; border:2px solid #30323a; border-radius:10px; box-shadow:0 0 60px rgba(0,0,0,0.6); display:block; }
#overlay { position:absolute; inset:0; border-radius:10px; background: rgba(6,7,10,0.88); display:flex; align-items:center; justify-content:center; z-index:30; cursor:pointer; }
#overlay.hidden { display:none; }
.card { text-align:center; padding:28px 24px; border:1px solid #30323a; border-radius:14px; background:#12141c; max-width:80%; }
.card h1 { margin:0 0 10px; font-size:2rem; color:#00e676; letter-spacing:2px; }
.card p { margin:0 0 12px; line-height:1.5; }
.card .hint { margin-top:14px; font-size:0.85rem; color:#71727a; }
```
No horizontal scrollbar because wrapper max-width and body overflow hidden.
JS:
```js
const canvas = document.getElementById('game');
const ctx = canvas.getContext('2d');
const scoreEl = document.getElementById('scoreEl');
const bestEl = document.getElementById('bestEl');
const overlay = document.getElementById('overlay');
const COLS = 20, ROWS = 20;
let cellSize = 24;
let width, height;
let state = 'DEMO';
let score = 0, bestScore = 0;
let dir = {x:1,y:0};
let turnQueue = [];
let snake = [];
let prevSnake = [];
let food = {x:0,y:0};
let tickTimer = 0;
let tickRate = 120;
let renderT = 0;
let particles = [];
let lastTime = 0;
function lerp(a,b,t){ return a + (b-a)*t; }
function ease(t){ return t*t*(3-2*t); }
function resize(){ ... }
function resetDemo(){ ... }
function resetGame(){ ... resetDemo(); state='PLAYING'; tickTimer=0; renderT=0; }
function placeFood(){ ... }
function spawnParticles(cx,cy){ ... }
function updateParticles(dt){ ... }
function aiChoose(){ ... }
function doTick(){ ... }
function gameOver(){ ... }
function togglePause(){ ... }
function startGame(){ ... }
function setOverlay(title,text,hint){ ... }
function update(dt){ ... }
function draw(){ ... }
function loop(ts){ ... }
// input handlers
window.addEventListener('keydown', ...);
document.getElementById('container').addEventListener('pointerdown', ...);
window.addEventListener('resize', resize);
resize();
resetDemo();
requestAnimationFrame(loop);
```
Careful: `resetDemo()` doesn't change state. Initially state DEMO. Good.
`drawHUD` called every frame:
```js
scoreEl.textContent = score;
bestEl.textContent = bestScore;
```
In `draw`, draw grid, food, particles, snake.
Let's ensure `prevSnake` initialized same length as snake.
Potential bug: In `doTick`, if `turnQueue` has multiple entries and we shift one per tick, that's fine. But if state is PLAYING and turnQueue empty, dir remains.
`doTick` wall/self check uses `head` computed from `snake[0]`. Good.
If game over in demo, `resetDemo()` resets score? Score in demo not shown anyway, but HUD shows score. Demo score shouldn't count. `resetDemo` sets score=0. Fine.
Let's write the AI choose carefully.
```js
function aiChoose() {
const dirs = [{x:0,y:-1},{x:0,y:1},{x:-1,y:0},{x:1,y:0}];
const cand = dirs.filter(d => !(d.x === -dir.x && d.y === -dir.y));
const safe = cand.filter(d => {
const hx = snake[0].x + d.x, hy = snake[0].y + d.y;
return hx >= 0 && hx < COLS && hy >= 0 && hy < ROWS &&
!snake.some(s => s.x === hx && s.y === hy);
});
const pick = safe.length ? safe : cand;
if (pick.length === 0) return dir;
// prefer closer to food
pick.sort((a,b) => {
const ha = {x: snake[0].x + a.x, y: snake[0].y + a.y};
const hb = {x: snake[0].x + b.x, y: snake[0].y + b.y};
const da = Math.abs(ha.x - food.x) + Math.abs(ha.y - food.y);
const db = Math.abs(hb.x - food.x) + Math.abs(hb.y - food.y);
return da - db;
});
return pick[0];
}
```
If safe empty but cand not empty (moving into wall/body), pick will be cand[0] causing game over next tick. Demo restarts automatically. Fine.
Particles color: gold/orange.
`drawParticles` uses `ctx.globalAlpha` based on life.
I think this covers everything. Let's write the final code block.
Double-check no external network. No images. Pure vanilla.
One more: requirement "Quick successive key presses must both be honored". My queue handles up to however many between ticks. Since tickRate ~120ms, human can press multiple keys between ticks, all queued. Good.
"the snake can never reverse onto itself" - checked both at enqueue and apply.
"new food never spawns on the snake" - `placeFood` loops until not on snake.
"Game over when snake hits wall or itself, with overlay showing score; Space or Enter restarts, P pauses." - implemented.
"Visual polish: smooth movement, snake with distinct head and eyes facing direction, particle burst." - implemented.
"Display current score and best score of session." - HUD.
Responsive to 375px: cellSize computed, no overflow.
First paint: demo draws immediately. Loop starts on load. Screenshot at 3s sees demo.
No localStorage.
I think it's solid. I'll now produce the code.
Wait, one subtlety: `ease(t)` vs `renderT`. I compute `renderT = Math.min(1, tickTimer / tickRate)` linear. Use `ease(renderT)` in lerp for smoother start/stop. Let's use `ease(renderT)`.
Also head eyes: when `renderT` between 0 and 1, head is between cells. Eyes facing `dir`. Fine.
Body color gradient: darker for tail segments. I'll vary alpha or color slightly by index: `ctx.fillStyle = i === 0 ? head : 'rgba(0,230,118,' + (0.6 + 0.4*(i/snake.length)) + ')'`. But since each segment drawn separately with fillStyle, okay.
Let's implement `drawBody(x,y)`:
```js
function drawBody(x,y, isTail){
const s = cellSize * (isTail ? 0.85 : 0.9);
const o = (cellSize - s)/2;
const r = 4;
ctx.fillStyle = '#00e676';
roundRect(ctx, x+o, y+o, s, s, r);
}
```
Tail maybe rounder/capsule. Fine.
Now final output: exactly one fenced code block with ```html ... ```. No text outside.
Let's write it.