Dots3-Note Preview (free)
by Dots StudioReasoning1 console error
Running live in a sandboxed frame. Click inside it before using the keyboard.
How it went
- Total time
- 3 min 58 s
- Cost
- $0
- Output tokens
- 19,367
- Spent thinking
- 14,968 tokens
- Speed
- 81.7 tok/s
- Console errors
- 1
- First token after
- 842 ms
- First visible text after
- 3 min 15 s
- Prompt tokens
- 493
- Code size
- 14.5 KB
- Finish reason
- stop
- Served by
- AtlasCloud
- Model id
dots-studio/dots-3-note-preview:free- Reasoning sent
model default- Max output
- 32,000 tokens
- Generated
- Nov 9, 2026
Console errors
Caught in the first 3 seconds after load, in a sandboxed iframe like the one above.
Assignment to constant variable.
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 · 594 lines
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Snake Game</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
min-height: 100vh;
display: flex;
justify-content: center;
align-items: center;
background: linear-gradient(135deg, #2c3e50, #4ca1af);
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
overflow: hidden;
}
#game-container {
position: relative;
width: 360px;
height: 360px;
}
canvas {
display: block;
width: 100%;
height: 100%;
background: #111;
border: 3px solid #3498db;
border-radius: 8px;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.5);
}
.overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.75);
color: #fff;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
text-align: center;
font-size: 1.4rem;
font-weight: bold;
border-radius: 8px;
z-index: 10;
padding: 20px;
text-shadow: 1px 1px 3px rgba(0,0,0,0.8);
}
.overlay.hidden {
display: none;
}
.score-display {
position: absolute;
top: 10px;
left: 10px;
color: #fff;
font-size: 1rem;
font-weight: bold;
text-shadow: 1px 1px 2px #000;
z-index: 5;
pointer-events: none;
}
.best-score {
color: #ffd700;
}
</style>
</head>
<body>
<div id="game-container">
<canvas id="game"></canvas>
<div id="overlay" class="overlay hidden">
<div id="overlay-text">Click or press a key to start</div>
</div>
<div class="score-display">
Score: <span id="score">0</span> | Best: <span id="best-score" class="best-score">0</span>
</div>
</div>
<script>
(() => {
'use strict';
// --- Constants & Configuration ---
const COLS = 20;
const ROWS = 20;
const CELL_SIZE = 18;
const CANVAS_WIDTH = COLS * CELL_SIZE;
const CANVAS_HEIGHT = ROWS * CELL_SIZE;
const MOVE_INTERVAL = 0.15; // seconds per move
// --- Canvas Setup ---
const canvas = document.getElementById('game');
const ctx = canvas.getContext('2d');
canvas.width = CANVAS_WIDTH;
canvas.height = CANVAS_HEIGHT;
// --- DOM Elements ---
const overlay = document.getElementById('overlay');
const overlayText = document.getElementById('overlay-text');
const scoreDisplay = document.getElementById('score');
const bestScoreDisplay = document.getElementById('best-score');
// --- Game State ---
let snake, prevSnake, direction, directionQueue;
let food, score, bestScore;
let gameOver, paused, aiMode;
let moveProgress, lastTime;
let particles;
// --- Utility Functions ---
function clamp(value, min, max) {
return Math.max(min, Math.min(max, value));
}
function randomInt(min, max) {
return Math.floor(Math.random() * (max - min + 1)) + min;
}
// --- Particle System ---
function createParticles(x, y) {
const colors = ['#f1c40f', '#e67e22', '#e74c3c', '#9b59b6', '#3498db'];
for (let i = 0; i < 18; i++) {
particles.push({
x: x * CELL_SIZE + CELL_SIZE / 2,
y: y * CELL_SIZE + CELL_SIZE / 2,
vx: (Math.random() - 0.5) * 250,
vy: (Math.random() - 0.5) * 250,
size: Math.random() * 4 + 2,
life: Math.random() * 0.6 + 0.4,
maxLife: 1.0,
color: colors[randomInt(0, colors.length - 1)]
});
}
}
function updateParticles(dt) {
for (let i = particles.length - 1; i >= 0; i--) {
const p = particles[i];
p.x += p.vx * dt;
p.y += p.vy * dt;
p.life -= dt;
if (p.life <= 0) {
particles.splice(i, 1);
}
}
}
function drawParticles() {
for (const p of particles) {
const alpha = clamp(p.life / p.maxLife, 0, 1);
ctx.globalAlpha = alpha;
ctx.fillStyle = p.color;
ctx.beginPath();
ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2);
ctx.fill();
}
ctx.globalAlpha = 1;
}
// --- Food Generation ---
function generateFood() {
let newFood;
do {
newFood = {
x: randomInt(0, COLS - 1),
y: randomInt(0, ROWS - 1)
};
} while (snake.some(seg => seg.x === newFood.x && seg.y === newFood.y));
food = newFood;
}
// --- Drawing Functions ---
function drawGrid() {
ctx.strokeStyle = '#1a1a1a';
ctx.lineWidth = 1;
// Vertical lines
for (let x = 0; x <= COLS; x++) {
ctx.beginPath();
ctx.moveTo(x * CELL_SIZE, 0);
ctx.lineTo(x * CELL_SIZE, ROWS * CELL_SIZE);
ctx.stroke();
}
// Horizontal lines
for (let y = 0; y <= ROWS; y++) {
ctx.beginPath();
ctx.moveTo(0, y * CELL_SIZE);
ctx.lineTo(COLS * CELL_SIZE, y * CELL_SIZE);
ctx.stroke();
}
}
function drawFood() {
const cx = food.x * CELL_SIZE + CELL_SIZE / 2;
const cy = food.y * CELL_SIZE + CELL_SIZE / 2;
const radius = CELL_SIZE / 2 - 2;
// Glow effect
ctx.shadowColor = '#e74c3c';
ctx.shadowBlur = 15;
ctx.fillStyle = '#e74c3c';
ctx.beginPath();
ctx.arc(cx, cy, radius, 0, Math.PI * 2);
ctx.fill();
ctx.shadowBlur = 0;
}
function drawSnake() {
const progress = clamp(moveProgress / MOVE_INTERVAL, 0, 1);
const len = snake.length;
for (let i = 0; i < len; i++) {
const prev = prevSnake[i] || snake[i];
const cur = snake[i];
const ix = prev.x + (cur.x - prev.x) * progress;
const iy = prev.y + (cur.y - prev.y) * progress;
const px = ix * CELL_SIZE + CELL_SIZE / 2;
const py = iy * CELL_SIZE + CELL_SIZE / 2;
const radius = CELL_SIZE / 2 - 1;
if (i === 0) {
// Head with eyes
drawHead(px, py, radius, direction);
} else {
// Body segment
ctx.fillStyle = '#2ecc71';
ctx.beginPath();
ctx.arc(px, py, radius, 0, Math.PI * 2);
ctx.fill();
ctx.strokeStyle = '#27ae60';
ctx.lineWidth = 1.5;
ctx.stroke();
}
}
}
function drawHead(px, py, radius, dir) {
// Head body
ctx.fillStyle = '#27ae60';
ctx.beginPath();
ctx.arc(px, py, radius, 0, Math.PI * 2);
ctx.fill();
// Darker top
ctx.fillStyle = '#218c74';
ctx.beginPath();
ctx.arc(px, py, radius - 2, 0, Math.PI * 2);
ctx.fill();
// Eyes
const eyeRadius = radius * 0.28;
const eyeDist = radius * 0.45;
const perpX = -dir.y;
const perpY = dir.x;
// Eye positions
const eye1X = px + dir.x * eyeDist * 0.3 + perpX * radius * 0.3;
const eye1Y = py + dir.y * eyeDist * 0.3 + perpY * radius * 0.3;
const eye2X = px + dir.x * eyeDist * 0.3 - perpX * radius * 0.3;
const eye2Y = py + dir.y * eyeDist * 0.3 - perpY * radius * 0.3;
// White part
ctx.fillStyle = '#fff';
ctx.beginPath();
ctx.arc(eye1X, eye1Y, eyeRadius, 0, Math.PI * 2);
ctx.arc(eye2X, eye2Y, eyeRadius, 0, Math.PI * 2);
ctx.fill();
// Pupils
ctx.fillStyle = '#000';
const pupilDist = eyeRadius * 0.5;
const pupil1X = eye1X + dir.x * pupilDist;
const pupil1Y = eye1Y + dir.y * pupilDist;
const pupil2X = eye2X + dir.x * pupilDist;
const pupil2Y = eye2Y + dir.y * pupilDist;
ctx.beginPath();
ctx.arc(pupil1X, pupil1Y, eyeRadius * 0.5, 0, Math.PI * 2);
ctx.arc(pupil2X, pupil2Y, eyeRadius * 0.5, 0, Math.PI * 2);
ctx.fill();
}
function drawOverlay() {
if (!overlayText) return;
ctx.fillStyle = 'rgba(0, 0, 0, 0.75)';
ctx.fillRect(0, 0, CANVAS_WIDTH, CANVAS_HEIGHT);
ctx.fillStyle = '#fff';
ctx.font = 'bold 16px "Segoe UI", sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
const lines = overlayText.split('\n');
const lineHeight = 24;
const startY = CANVAS_HEIGHT / 2 - (lines.length - 1) * lineHeight / 2;
lines.forEach((line, i) => {
ctx.fillText(line, CANVAS_WIDTH / 2, startY + i * lineHeight);
});
}
function render() {
// Clear background
ctx.fillStyle = '#0d1117';
ctx.fillRect(0, 0, CANVAS_WIDTH, CANVAS_HEIGHT);
drawGrid();
drawFood();
drawParticles();
drawSnake();
drawOverlay();
}
// --- AI Logic ---
function getAIDirection() {
const dirs = [
{ x: 0, y: -1 }, // up
{ x: 0, y: 1 }, // down
{ x: -1, y: 0 }, // left
{ x: 1, y: 0 } // right
];
const head = snake[0];
const candidates = [];
for (const d of dirs) {
// Skip opposite direction
if (d.x === -direction.x && d.y === -direction.y) continue;
const nx = head.x + d.x;
const ny = head.y + d.y;
// Wall check
if (nx < 0 || nx >= COLS || ny < 0 || ny >= ROWS) continue;
// Self collision (ignore tail if not eating)
let collision = false;
const willEat = (nx === food.x && ny === food.y);
for (let i = 0; i < snake.length; i++) {
if (!willEat && i === snake.length - 1) continue; // tail will move
if (snake[i].x === nx && snake[i].y === ny) {
collision = true;
break;
}
}
if (!collision) {
candidates.push(d);
}
}
if (candidates.length === 0) {
// Fallback: pick any non-opposite direction
for (const d of dirs) {
if (!(d.x === -direction.x && d.y === -direction.y)) {
return d;
}
}
return direction;
}
// Choose direction that minimizes Manhattan distance to food
let bestDir = candidates[0];
let bestDist = manhattanDistance(head, candidates[0], food);
for (const d of candidates) {
const dist = manhattanDistance(head, d, food);
if (dist < bestDist) {
bestDist = dist;
bestDir = d;
}
}
return bestDir;
}
function manhattanDistance(head, dir, food) {
const nx = head.x + dir.x;
const ny = head.y + dir.y;
return Math.abs(nx - food.x) + Math.abs(ny - food.y);
}
// --- Game Logic ---
function processMove() {
// AI input in demo mode
if (aiMode) {
directionQueue.push(getAIDirection());
}
// Dequeue next valid direction
let newDirection = direction;
while (directionQueue.length > 0) {
const d = directionQueue.shift();
if (!(d.x === -direction.x && d.y === -direction.y)) {
newDirection = d;
break;
}
}
direction = newDirection;
const head = snake[0];
const newHead = {
x: head.x + direction.x,
y: head.y + direction.y
};
// Collision detection
const willEat = (newHead.x === food.x && newHead.y === food.y);
let collision = false;
// Wall collision
if (newHead.x < 0 || newHead.x >= COLS || newHead.y < 0 || newHead.y >= ROWS) {
collision = true;
} else {
// Body collision
for (let i = 0; i < snake.length; i++) {
if (snake[i].x === newHead.x && snake[i].y === newHead.y) {
// Tail will be removed if not eating
if (!willEat && i === snake.length - 1) continue;
collision = true;
break;
}
}
}
if (collision) {
gameOver = true;
bestScore = Math.max(bestScore, score);
bestScoreDisplay.textContent = bestScore;
overlayText = `Game Over!\nScore: ${score}\nBest: ${bestScore}\nPress Space or Enter to restart`;
overlay.classList.remove('hidden');
return;
}
// Save old snake for interpolation
const oldSnake = snake.map(p => ({ ...p }));
if (willEat) {
// Grow: prepend old head to prevSnake
prevSnake = [{ ...oldSnake[0] }, ...oldSnake];
snake = [newHead, ...oldSnake];
score += 10;
scoreDisplay.textContent = score;
generateFood();
createParticles(food.x, food.y);
} else {
// Normal move
prevSnake = oldSnake;
snake = [newHead, ...oldSnake.slice(0, -1)];
}
moveProgress = 0;
}
function update(dt) {
if (paused || gameOver) return;
moveProgress += dt;
while (moveProgress >= MOVE_INTERVAL) {
moveProgress -= MOVE_INTERVAL;
processMove();
}
updateParticles(dt);
}
// --- Input Handling ---
function handleKeyDown(e) {
let dir = null;
switch (e.key) {
case 'ArrowUp':
case 'w':
case 'W':
dir = { x: 0, y: -1 };
break;
case 'ArrowDown':
case 's':
case 'S':
dir = { x: 0, y: 1 };
break;
case 'ArrowLeft':
case 'a':
case 'A':
dir = { x: -1, y: 0 };
break;
case 'ArrowRight':
case 'd':
case 'D':
dir = { x: 1, y: 0 };
break;
case ' ':
case 'Enter':
if (gameOver) {
restartGame();
}
e.preventDefault();
return;
case 'p':
case 'P':
togglePause();
e.preventDefault();
return;
}
if (dir) {
// First interaction: switch to human control
if (aiMode && overlay.classList.contains('hidden') === false) {
aiMode = false;
overlay.classList.add('hidden');
overlayText = null;
}
directionQueue.push(dir);
// Prevent page scroll on arrow keys
if (e.key.startsWith('Arrow')) {
e.preventDefault();
}
}
}
function handleCanvasClick() {
if (aiMode && !overlay.classList.contains('hidden')) {
aiMode = false;
overlay.classList.add('hidden');
overlayText = null;
}
}
// --- Game Control ---
function init() {
snake = [
{ x: 10, y: 10 },
{ x: 9, y: 10 },
{ x: 8, y: 10 }
];
prevSnake = snake.map(p => ({ ...p }));
direction = { x: 1, y: 0 };
directionQueue = [];
score = 0;
scoreDisplay.textContent = '0';
gameOver = false;
paused = false;
aiMode = true;
moveProgress = 0;
particles = [];
generateFood();
overlayText = 'Click or press a key to start';
overlay.classList.remove('hidden');
}
function restartGame() {
init();
}
function togglePause() {
if (gameOver) return;
paused = !paused;
if (paused) {
overlayText = 'Paused\nPress P to resume';
overlay.classList.remove('hidden');
} else {
overlay.classList.add('hidden');
overlayText = null;
}
}
// --- Main Loop ---
function loop(timestamp) {
if (!lastTime) lastTime = timestamp;
const dt = Math.min((timestamp - lastTime) / 1000, 0.1);
lastTime = timestamp;
update(dt);
render();
requestAnimationFrame(loop);
}
// --- Event Listeners ---
window.addEventListener('keydown', handleKeyDown);
canvas.addEventListener('click', handleCanvasClick);
// --- Start ---
init();
requestAnimationFrame(loop);
})();
</script>
</body>
</html>Reasoning trace (14,968 tokens)
We need to output a single HTML file with canvas, JS, CSS. Must be self-contained. No external resources. Must be polished. Requirements: grid at least 20x20, centered. Arrow keys and WASD. Quick successive key presses must be honored, but snake can't reverse onto itself. Eating food adds 10 points and grows by one cell; new food never spawns on snake. Game over when hitting wall or itself, overlay showing score; Space or Enter restarts, P pauses. Visual polish: smooth movement, snake with distinct head and eyes facing direction, small particle burst when eating. Display current score and best score of session.
We need to implement a Snake game with smooth movement (lerp between cells), maybe using requestAnimationFrame and updating position each frame with a step per second. Or we can use discrete steps but smooth interpolation. We'll implement a snake as array of segments, each with x,y in grid coordinates. For smooth movement, we can have a "progress" variable between 0 and 1, moving the snake head from current cell to next cell. Each step, we add a new segment at the head direction, and if not eating, remove tail. For smooth, we can store positions as floating point and interpolate.
Simplify: Use a fixed timestep, e.g., move every 150ms, but we can interpolate for smoothness. Or use requestAnimationFrame and update position based on time delta, moving the snake continuously. We'll implement a snake with a head that has direction, and body segments. We'll use a grid of 20x20 cells, each cell size maybe 20px, canvas size 400x400 plus maybe some margin. We'll center the canvas.
We need to handle input: arrow keys and WASD. Also quick successive key presses must be honored, meaning we should queue direction changes but not allow reversing. So we can store a direction queue, but ensure that new direction is not opposite of current direction.
We'll implement a state machine: running, paused, gameover.
We'll display score and best score. Best score stored in sessionStorage? But we cannot use sessionStorage (blocked). The requirement says "best score of the session" but we can store in memory variable (e.g., let bestScore = 0). That's fine.
We need a particle burst when eating. We'll create an array of particles with positions, velocities, life, and draw them.
We'll make it visually polished: background gradient, snake color, head with eyes that rotate based on direction. We'll draw eyes as two small circles positioned relative to head direction.
We'll also show overlay with score and instructions.
We'll handle restart with Space or Enter, pause with P.
We'll ensure no horizontal scrollbar, responsive. Use CSS to center canvas, maybe max-width:100%.
We'll include a "Click or press a key to start" message initially. The game should start in a self-playing demo mode? The instruction says: "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." So we need to implement a simple AI that plays automatically until user interacts. So we should have an AI that moves the snake randomly or towards food. But we need to show the game in action. So we can start the game in "demo mode" where the AI controls the snake. When user presses a key or clicks, it switches to player control.
We need to implement AI: simple random direction or pathfinding? Could be a simple AI that moves towards food using basic steering. But must avoid immediate reversal. We can implement a simple AI that chooses a direction based on food location, but avoid walls and itself. Or we can just have it move randomly but not reverse.
We'll implement a function aiMove() that picks a direction from possible ones (not opposite, not into wall or self). Prefer direction that reduces distance to food.
But we need to ensure the demo is visible within first seconds. So we'll start the game in "demo" mode with AI playing. The overlay will say "Click or press a key to start". When user interacts, we switch to human control and hide overlay. Also if game over, overlay shows score and restart instructions.
We also need to handle pause: P toggles pause, but only when game is running and not gameover.
We'll implement smooth movement using interpolation. Let's define each cell size, e.g., 20px. Snake head position as float x,y. Each frame, we update a "progress" that increases by dt * speed. When progress >= 1, we commit to next cell, update direction, etc.
Alternatively, we can use a simpler approach: update snake position every frame by moving head in direction by speed, and when it crosses into next cell, we add new segment. But need to handle growth.
Better: Use a "snake" array of objects {x, y} representing grid coordinates of each segment. Also a "nextDirection" and "direction". For smooth movement, we can store a "lerp" value between current cell and next cell. Actually, we can store the head's exact pixel position as float, and each segment follows a path.
Simplify: Use a "snake" array of points (x,y) in grid coordinates. For smooth rendering, we can interpolate between previous position and current position each frame. We'll have a "oldSnake" array that stores previous positions, and we interpolate. Or we can store a "progress" for the head, and the body segments follow the path of the head with some delay.
Classic approach: Store a queue of positions for the snake body. Each frame, we move the head forward by a small step, and if the distance moved exceeds cell size, we snap to grid and update direction. But for smoothness, we can interpolate between the last two grid positions.
I'll implement a snake with a head at floating coordinates (x, y) in pixels. The snake moves in a direction vector. Each frame, we move the head by speed * dt. When the head crosses a grid line, we add a new segment at the previous grid cell? Actually, we need to maintain body length. The body follows the head's path. We can store a history of head positions (array) and each body segment corresponds to a past position. When the head moves a distance equal to cell size, we push the current head position (grid aligned) into history and limit history length to snake length. For smooth rendering, we can draw segments at positions interpolated between history entries.
Simplify: Use a fixed step movement with interpolation. Let's define a variable "moveTimer" that accumulates delta time. When moveTimer exceeds moveInterval (e.g., 150ms), we perform a discrete move: update direction, move head to new cell, add new segment if growing, else remove tail. Then reset moveTimer. For smooth rendering, we can interpolate between the previous head position and the new head position based on the fraction of moveInterval elapsed. This is common.
We'll store snake as array of grid positions (x,y). Also store "prevHead" for interpolation. During rendering, we compute interpolated positions for each segment? Actually, only the head moves; the body segments are static until next move. But we can interpolate the entire snake by storing previous positions for each segment. Simpler: store two states: current snake and previous snake. Then for each segment, interpolate between previous and current based on progress. This yields smooth movement.
Implementation plan:
- Define grid size: cols = 20, rows = 20.
- Cell size: 20px, canvas size = cols*cellSize = 400x400.
- Center canvas with CSS.
- State variables:
- snake: array of {x, y} (grid coordinates) current.
- prevSnake: array of {x, y} previous.
- direction: current direction (dx, dy).
- nextDirection: queued direction.
- food: {x, y}.
- score: number.
- bestScore: number.
- gameOver: boolean.
- paused: boolean.
- moveProgress: number between 0 and 1 for interpolation.
- moveInterval: e.g., 0.15 seconds? Actually we can use a fixed step, e.g., 0.15s. We'll use a accumulator.
- lastTime: timestamp.
- particles: array of particle objects.
- aiMode: boolean (demo mode).
- overlayText: string.
- Input handling:
- keydown event: map keys to direction.
- ArrowUp, W: up (0, -1)
- ArrowDown, S: down (0, 1)
- ArrowLeft, A: left (-1, 0)
- ArrowRight, D: right (1, 0)
- Space or Enter: restart if gameOver.
- P: pause toggle.
- Prevent default for arrow keys to avoid scrolling.
- For quick successive key presses, we can set nextDirection but ensure it's not opposite of current direction. Also we can allow multiple queued moves? The requirement: "Quick successive key presses must both be honored, but the snake can never reverse onto itself." So if player presses two keys quickly, both should be applied in order, but each move must not reverse. So we can have a queue of directions. However, typical snake games only apply the latest direction if it's not opposite. But they want quick successive presses to be honored, meaning if you press right then up quickly, the snake should go right then up, not just up. So we need a direction queue. We'll implement a queue (array) of directions. When we process a move, we dequeue the next direction (if any) and set it as current direction, but ensure it's not opposite of current direction? Actually, we need to ensure that the snake never reverses onto itself. So when we dequeue a direction, we check if it's opposite of current direction; if so, we ignore it. Or we can filter the queue to remove any direction that would be opposite at the time of processing. But we must also ensure that the direction is valid (not into wall or self). However, the snake might die if it goes into wall, but that's fine.
Simplify: We'll have a queue of pending directions. When we process a move, we set direction to the first valid direction in queue (not opposite). If queue is empty, keep current direction. This way, quick presses are queued and applied sequentially.
But also need to handle AI moves when in demo mode. AI will also queue directions.
We'll implement a function queueDirection(dir) that adds to queue if not opposite of current direction? Actually, we can add any direction, but when processing, we filter out opposite.
Alternatively, we can just set nextDirection and apply it at next move, but if multiple presses happen before next move, only the last one is kept. The requirement says quick successive key presses must both be honored, so we need to keep them.
Thus, implement a queue.
- Movement logic:
- Use requestAnimationFrame loop with dt.
- Accumulate time. When accumulator >= moveInterval, perform a move step.
- In move step:
- Determine next direction: if queue not empty, dequeue direction; if it's opposite of current direction, skip it and try next? Actually, we should ensure the snake never reverses onto itself. So we can dequeue a direction, and if it's opposite, we ignore it and continue dequeuing until we find a non-opposite or queue empty. But if the queue has multiple directions, we need to apply them in order, but each move step uses one direction. So we can set current direction to the dequeued direction (if valid). If it's opposite, we skip it and don't change direction (or we could ignore that move). But then the snake might not turn as intended. Better: when adding to queue, we can filter out any direction that is opposite of the current direction at the time of adding? But the current direction might change before the move is processed. So we need to handle at processing time.
- After determining direction, compute new head position: headX + dir.x, headY + dir.y.
- Check collision with walls: if out of bounds -> gameOver.
- Check collision with own body: if new head position matches any segment (except maybe the tail if not growing) -> gameOver. But careful: if the snake is growing, the tail doesn't move, so collision with tail is possible. We'll check collision against all body segments except the one that will be removed? Actually, if we are not eating food, we will remove the tail. So the new head should not collide with any segment except possibly the tail if we are eating? Wait, when moving, we first add new head, then if not eating, remove tail. So the new head could collide with the current tail position, but after removing tail, it's safe. So we need to check collision against all segments except the one that will be removed (the tail) if not growing. However, if the snake is length 1, there is no body. So we can check collision against all segments except the last one (tail) when not growing. But if growing, we keep all segments, so collision with any segment (including tail) is fatal. So we need to handle accordingly.
- If no collision:
- Update direction to new direction.
- Update prevSnake = current snake (copy).
- Add new head to snake.
- If food at new head position:
- Increase score by 10.
- Generate new food (not on snake).
- Create particle burst at food position.
- Do not remove tail (growth).
- Else:
- Remove tail (pop last element).
- Set moveProgress = 0 (will be interpolated in render).
- Else (collision):
- gameOver = true.
- Show overlay with score.
- In render:
- Clear canvas.
- Draw background.
- Draw grid lines maybe.
- Draw food.
- Draw particles.
- Draw snake with interpolation: for each segment i, compute interpolated position between prevSnake[i] and snake[i] based on moveProgress. If prevSnake has fewer segments (e.g., after growth), we can handle by using the current snake for missing prev. Or we can store prevSnake of same length by duplicating the new segment? Simpler: after each move, we set prevSnake = snake.map(p => ({...p})). Then during render, for each i, we interpolate between prevSnake[i] and snake[i]. If lengths differ, we can use snake[i] for missing prev. But lengths should be same because we update both before changing length? Actually, we update prevSnake before adding new head and possibly removing tail. So lengths will be same. However, when we grow, we add new head without removing tail, so snake length increases by 1. But prevSnake was set before adding new head, so it has old length. Then during interpolation, we have mismatch. To fix, we can set prevSnake after the move? But we need previous positions for interpolation. Alternative: store a history array for each segment? Or we can interpolate only the head and have body segments follow smoothly by storing a queue of past head positions.
Simplify: Use a "snakePath" array of points (x,y) that the head has visited, with each point representing a grid cell. The body segments are the last N points in the path. The head is the first point. For smooth movement, we can store the head's fractional position between the last two path points. This is a common approach.
Let's adopt that:
- Maintain an array "path" of grid coordinates (x,y) that the snake's head has occupied, in order from oldest to newest. The head is at the end of the path. The body consists of the last `length` points (including head). Actually, we can treat the snake as a list of segments, each with a position. For smooth movement, we can store a "progress" for the head moving from the second-to-last path point to the last path point. But the body segments also need to move smoothly. If we just use path, each body segment is exactly at a past grid position, which would be choppy. To get smooth movement, we can interpolate each segment between its previous and current grid positions. So we need to store both current and previous positions for each segment.
Thus, the two-array approach is fine. We can handle length change by adjusting prevSnake when we grow. When we add a new head, we can set prevSnake to have the same length by duplicating the previous head? Actually, before adding new head, we have snake of length L. We set prevSnake = copy of snake. Then we add new head (making length L+1). Now prevSnake length L, snake length L+1. For interpolation, for i < L, we interpolate between prevSnake[i] and snake[i]. For the new head (i = L), we can just use snake[L] (no interpolation) or set prevSnake[L] = snake[L-1] (the old head) so it moves from old head to new head. That would give smooth movement for the new segment as well. So we can push a copy of the old head to prevSnake when growing. Similarly, when removing tail, we just pop from both arrays? Actually, we remove tail from snake, so snake length becomes L. We also need to remove corresponding entry from prevSnake to keep same length. So after move, we can adjust prevSnake to match snake length.
Implementation steps for move:
- Determine direction.
- Compute newHead = {x: head.x + dir.x, y: head.y + dir.y}.
- Check collision.
- If no collision:
- Set prevSnake = snake.map(p => ({...p})); // copy current snake
- If eating food:
- snake.unshift(newHead); // add to front
- // prevSnake currently has old length; we need to add a corresponding entry for interpolation. We can set prevSnake.unshift({x: head.x, y: head.y}) i.e., the old head position. So the new head will interpolate from old head to newHead.
- // No tail removal.
- Else:
- snake.unshift(newHead);
- snake.pop();
- // prevSnake also needs to have same length. Since we copied before modifications, prevSnake length = old length. After unshift and pop, snake length unchanged. But prevSnake still has old order. For interpolation, we need prevSnake to correspond to previous positions of each segment. Since we added new head and removed tail, the mapping shifts. Ideally, prevSnake should be the previous positions of the current segments. Before move, we had segments A (head), B, C, ... tail. After move, new head is at newHead, old head becomes second segment, etc., and tail is removed. So the previous positions for the new segments are: for new head, previous position is old head; for second segment (old head), previous position is old second segment; ... for new tail (old second-to-last), previous position is old tail. So we can achieve this by: prevSnake = snake (old) copy. Then we do snake.unshift(newHead); snake.pop(); Then we need to adjust prevSnake to match the new snake length and order. Actually, we can set prevSnake = old snake (before move) but then we need to shift it appropriately. Simpler: after computing new snake, we can set prevSnake = old snake but with the same length. Since we removed tail, we can pop from prevSnake? But we also need to add old head at the end? Let's think.
Better approach: Use a "snake" array of segments, each segment has current position (x,y) and previous position (prevX, prevY). Each move, we update each segment's prev = current, then set head's current to new position, and for body segments, we shift their current to the previous segment's old current? Actually, we can implement a classic snake where each segment follows the path of the head. We can store a queue of positions for the head. The body segments are just the last N positions. For smooth rendering, we can interpolate each segment between its two nearest path points based on a global progress.
Simpler: Use a "snake" array of objects with x, y (grid). For smooth rendering, we can store a "lerp" value for the head only, and the body segments just follow the head's path with a delay. But that might not be smooth for body.
Given the time, I'll implement the two-array method with careful handling.
Pseudo:
let snake = [{x:10,y:10}, {x:9,y:10}, {x:8,y:10}];
let prevSnake = snake.map(p=>({...p}));
let direction = {x:1,y:0};
let moveProgress = 0;
const moveInterval = 0.15; // seconds
function update(dt) {
moveProgress += dt;
if (moveProgress >= moveInterval) {
moveProgress = 0;
// process move
// determine direction from queue
let dir = dequeueDirection();
if (dir) {
// check not opposite
if (!(dir.x === -direction.x && dir.y === -direction.y)) {
direction = dir;
}
}
// compute new head
let head = snake[0];
let newHead = {x: head.x + direction.x, y: head.y + direction.y};
// collision check
let collide = false;
if (newHead.x < 0 || newHead.x >= cols || newHead.y < 0 || newHead.y >= rows) {
collide = true;
} else {
// check body collision, but ignore tail if not growing
for (let i = 0; i < snake.length; i++) {
if (snake[i].x === newHead.x && snake[i].y === newHead.y) {
// if this segment is the tail and we are not eating food, it's safe
if (i === snake.length - 1 && !eating) {
continue;
}
collide = true;
break;
}
}
}
if (collide) {
gameOver = true;
showOverlay();
return;
}
// set prevSnake to current snake (deep copy)
prevSnake = snake.map(p => ({...p}));
// update snake
let eating = (newHead.x === food.x && newHead.y === food.y);
snake.unshift(newHead);
if (!eating) {
snake.pop();
} else {
// grow: don't pop
// also need to adjust prevSnake to have same length as snake for interpolation
// Since we copied before unshift, prevSnake length is old length.
// For the new head, we want its previous position to be the old head.
// So we can prepend old head to prevSnake.
prevSnake.unshift({x: head.x, y: head.y});
// Now prevSnake length = snake length.
// But we also need to ensure that for the tail segment (which didn't move), its prev is same as current? Actually, the tail segment's current position is unchanged, but its previous position should be the same? Since we didn't remove tail, the tail segment's current is still the same, but we want it to interpolate from its previous position (which is the same) to current? That would be no movement. That's fine.
// However, the old head (now second segment) should interpolate from its old position (which is prevSnake[1] after unshift? Let's check.
// Before move: snake = [head, seg1, seg2, ... tail].
// prevSnake = copy of that.
// After unshift newHead: snake = [newHead, head, seg1, seg2, ... tail].
// We unshift old head to prevSnake: prevSnake = [oldHead, head, seg1, seg2, ... tail].
// Now for index 0 (newHead), prev = oldHead, cur = newHead -> interpolate.
// For index 1 (head), prev = head (old position), cur = head (same) -> no movement.
// That's not correct: the second segment should move from its old position (seg1) to head's old position? Actually, in classic snake, each segment follows the path of the head. When the head moves, the second segment moves to the previous position of the head, the third segment moves to the previous position of the second segment, etc. So after a move, the second segment's new position should be the old head position. In our representation, we have snake[1] = head (old head). That's correct. But its previous position should be the old position of seg1 (which was snake[2] before move). In our prevSnake, after unshift oldHead, prevSnake[1] = head (old head). That's not correct; it should be the old position of seg1. So our simple unshift doesn't properly shift the previous positions.
Thus, the two-array approach requires shifting prevSnake accordingly. When we add a new head and keep the same length (no growth), we need to set prevSnake such that each segment's previous position is the old position of the segment that was at that index before the move. Specifically, after move, snake[i] (for i>0) should have previous position = old snake[i-1]. For i=0, previous = old head. So we can construct prevSnake as: prevSnake = [oldHead, ...oldSnake.slice(0, -1)]? Let's derive.
Let oldSnake = [h, s1, s2, ..., t] (length L).
After move (no growth), newSnake = [newHead, h, s1, s2, ..., t-1] (length L). Actually, we remove tail, so newSnake = [newHead, h, s1, s2, ..., s_{L-2}] (since tail removed). The previous positions for each segment:
- For newHead: prev = h
- For h: prev = s1
- For s1: prev = s2
- ...
- For new tail (which was s_{L-2}): prev = s_{L-1} (old tail)
So prevSnake should be [h, s1, s2, ..., s_{L-1}] i.e., oldSnake. Wait, that's exactly oldSnake! Because oldSnake = [h, s1, s2, ..., s_{L-2}, t] where t is tail. But we need prevSnake length L. The last element of prevSnake should be the previous position of the new tail, which is old tail (t). So prevSnake = [h, s1, s2, ..., s_{L-2}, t]. That's exactly oldSnake. But note that newSnake has elements: newHead, h, s1, s2, ..., s_{L-2}. So prevSnake should be oldSnake, but oldSnake has L elements, newSnake has L elements. However, the last element of prevSnake (t) corresponds to the previous position of the new tail (s_{L-2}), which is correct. But the first element of prevSnake (h) corresponds to previous position of newHead. So indeed, if we set prevSnake = oldSnake (before move), then after we compute newSnake, we can interpolate between prevSnake[i] and newSnake[i] for each i. Let's test with indices:
oldSnake: [h, s1, s2, t] (L=4)
newSnake: [newHead, h, s1, s2] (tail t removed)
prevSnake = oldSnake = [h, s1, s2, t]
Interpolation:
i=0: prev=h, cur=newHead -> good.
i=1: prev=s1, cur=h -> good (second segment moves from s1 to h).
i=2: prev=s2, cur=s1 -> good.
i=3: prev=t, cur=s2 -> good (new tail moves from t to s2).
Works!
Now for growth case (eating):
oldSnake = [h, s1, s2, t] (L=4)
newSnake = [newHead, h, s1, s2, t] (L=5) (no removal)
We need prevSnake of length 5 such that:
i=0: prev = h
i=1: prev = s1? Actually, second segment is h, its previous should be s1? Wait, after move, the second segment (which was head) should move from its old position? In growth, the snake length increases, the body segments shift similarly but tail stays. So the second segment (h) should move from s1 to h? Actually, the head moves to newHead, the old head becomes second segment, but its position is still h (it hasn't moved yet). In the next move, it will move to s1, etc. But for interpolation, we want the second segment to interpolate from its old position (which was h) to its new position (h)? That would be no movement. That's not correct. Actually, in the growth step, the snake grows, but the existing body segments do not move; only the head moves to a new cell, and a new segment is added at the old head position? Wait, typical snake growth: when eating, the snake's head moves to the food, and the tail does not shrink, so the snake length increases by 1. The body segments remain in their current positions; only the head moves forward. So the old head becomes the second segment, but its position is still the same cell (the old head position). So the second segment does not move; it stays at h. The third segment stays at s1, etc. So the only movement is the head moving from h to newHead. The new segment is effectively the old head position, but it's already there as the second segment. So we don't need to interpolate the body segments; they stay put. However, for smooth rendering, we might want to interpolate the head from h to newHead, and the rest just stay. So we can set prevSnake = oldSnake with an extra element at the end? Actually, we need prevSnake to have same length as newSnake (5). The first element (newHead) should have prev = h. The second element (h) should have prev = h (since it doesn't move). The third (s1) prev = s1, etc. So prevSnake could be [h, h, s1, s2, t]? But that would cause the second segment to interpolate from h to h (no movement), fine. But then the third segment would interpolate from s1 to s1? Actually, newSnake[2] = s1, prevSnake[2] = s1 -> no movement. That works. However, we also need to consider that the old tail t remains, and its previous position is t (no movement). So we can construct prevSnake by taking oldSnake and duplicating the head? Let's think: oldSnake = [h, s1, s2, t]. newSnake = [newHead, h, s1, s2, t]. We want prevSnake such that:
prev[0] = h (old head)
prev[1] = h (since second segment is h and stays)
prev[2] = s1
prev[3] = s2
prev[4] = t
So prevSnake = [h, ...oldSnake] i.e., oldSnake with h prepended. That yields [h, h, s1, s2, t]. Perfect.
Thus, for growth: prevSnake = [{x: head.x, y: head.y}, ...oldSnake] (where head is old head). Then we set newSnake = [newHead, ...oldSnake] (since we don't pop). Actually, newSnake = [newHead, ...oldSnake] because we unshift newHead and keep all old segments. That matches.
For no growth: prevSnake = oldSnake (as is). newSnake = [newHead, ...oldSnake.slice(0, -1)] (unshift and pop). That matches.
So algorithm per move:
- oldSnake = snake (current)
- Determine direction, compute newHead.
- Check collision (using oldSnake, but with consideration of tail removal if not eating).
- If collision -> gameOver.
- Set eating = (newHead.x == food.x && newHead.y == food.y)
- If eating:
- prevSnake = [oldSnake[0], ...oldSnake] // prepend old head
- snake = [newHead, ...oldSnake] // length +1
- // generate new food, particles, score++
- Else:
- prevSnake = oldSnake // copy? Actually we need a copy, because we will modify snake. We can just use reference but then we need to copy later. Better to copy.
- snake = [newHead, ...oldSnake.slice(0, -1)] // length same
- moveProgress = 0
- direction = newDirection (if changed)
But careful: we need to copy oldSnake for prevSnake to avoid reference issues. So we can do: prevSnake = oldSnake.map(p => ({...p})).
For growth case, we need to prepend a copy of old head to prevSnake: prevSnake = [{...oldSnake[0]}, ...oldSnake].
For no growth, prevSnake = oldSnake.map(p => ({...p})).
Then after setting snake, we can interpolate.
Now, collision detection: we need to check against oldSnake (the snake before move). For growth, we check against all segments of oldSnake (since tail stays). For no growth, we check against all segments except the tail (since tail will be removed). So we can implement:
let bodyToCheck = eating ? oldSnake : oldSnake.slice(0, -1);
Then if newHead matches any segment in bodyToCheck -> collision.
Also check walls.
Now, direction queue: We'll maintain an array "directionQueue". When a key is pressed, we map to direction object {x,y}. We push to queue. But we should also prevent queue from growing infinitely? It's fine.
When processing a move, we dequeue directions until we find one that is not opposite of current direction. If queue is empty, we keep current direction.
But also we need to ensure that the direction we choose is not opposite of current direction. So we can do:
let newDirection = direction; // default keep
while (directionQueue.length > 0) {
let d = directionQueue.shift();
if (!(d.x === -direction.x && d.y === -direction.y)) {
newDirection = d;
break;
}
// else ignore this direction (opposite) and continue to next
}
direction = newDirection;
But what if the first queued direction is opposite, we skip it and try next. That's correct.
Now, AI for demo mode: We'll implement a simple AI that chooses a direction towards food while avoiding walls and self. We can use a simple algorithm: compute possible directions (up, down, left, right) that are not opposite of current direction and not into wall or self (except tail). Then choose the one that minimizes Manhattan distance to food. If multiple, choose randomly. Or we can just pick a random valid direction. To make it look decent, we'll use a simple greedy approach.
We'll have a function aiDecide() that returns a direction. We'll call it when in aiMode and we need to queue a direction. We can queue a new direction each move or each few moves. But we need to ensure the AI doesn't reverse. We'll compute possible directions and pick one.
We'll set aiMode = true initially. The game will start in demo mode. The overlay will show "Click or press a key to start". When user presses any key or clicks, we set aiMode = false and hide overlay, and also start the game if it was paused? Actually, we need to start the game loop. We'll have a running flag. Initially, the game is not running; we show overlay. When user clicks, we start the game (set running = true, aiMode = false). But we also want the demo to play automatically. So we can start the game loop immediately with aiMode = true, and show overlay with message. The game runs in background with AI. When user interacts, we set aiMode = false, hide overlay, and continue. If game over during AI, we show overlay with score and restart instructions. Then user can press Space/Enter to restart (which will start again with AI? Or with human? We can restart with AI again until user clicks). The requirement: "Games must start in a self-playing demo mode (a simple AI plays) behind a 'Click or press a key to start' message, so the game can be judged in action without playing." So initially, AI plays. When user clicks, they take over. If they die, they can restart with Space/Enter, and then it's human controlled? Probably after restart, it should be human controlled. But we can also restart with AI if no interaction? The requirement doesn't specify. We'll make it so that after a game over, the overlay shows score and "Press Space or Enter to restart". If user presses, we restart the game with human control (aiMode = false). If they don't, it stays. But the demo should be self-playing initially. So we'll start with aiMode = true and overlay "Click or press a key to start". While overlay is shown, the game is running with AI? Actually, we can start the game loop, but pause until user clicks? The requirement says "behind a 'Click or press a key to start' message", meaning the game is visible but overlay is on top. The game should be playing (AI) behind the overlay. So we start the game loop, AI moves, but we show overlay. When user clicks, we hide overlay and set aiMode = false. That's good.
We'll also handle pause with P. When paused, game freezes, overlay shows "Paused" maybe. But the requirement: "P pauses". So we can toggle pause state. When paused, we show overlay with "Paused" and maybe "Press P to resume". We'll implement.
Now, visual polish:
- Canvas background: maybe a dark color with subtle grid.
- Snake: body color green, head darker green, eyes white with black pupil. Eyes positioned based on direction.
- Food: red circle.
- Particle burst: small particles with random colors, expanding and fading.
- Smooth movement: interpolate positions.
- Score display: top left or top right, with best score.
We'll also draw a border around canvas.
Now, implementation details:
We'll set canvas width and height to cols * cellSize. We'll center with CSS: display: block; margin: 0 auto; background: #222; border: 2px solid #444.
We'll use requestAnimationFrame for loop.
We'll define variables:
const cols = 20;
const rows = 20;
const cellSize = 20;
const canvas = document.getElementById('game');
const ctx = canvas.getContext('2d');
canvas.width = cols * cellSize;
canvas.height = rows * cellSize;
let snake, prevSnake, direction, directionQueue, food, score, bestScore, gameOver, paused, moveProgress, moveInterval, particles, aiMode, overlay, overlayText, lastTime, accumulator;
moveInterval = 0.15; // 150ms per move initially, can adjust difficulty? Not required.
Initialize game:
function init() {
snake = [{x: 10, y: 10}, {x: 9, y: 10}, {x: 8, y: 10}];
direction = {x: 1, y: 0}; // facing right
directionQueue = [];
score = 0;
bestScore = 0; // or retrieve from variable
gameOver = false;
paused = false;
moveProgress = 0;
particles = [];
generateFood();
// set initial prevSnake same as snake
prevSnake = snake.map(p => ({...p}));
// AI mode
aiMode = true;
// show overlay
overlayText = "Click or press a key to start";
showOverlay(true);
}
function generateFood() {
let newFood;
do {
newFood = {
x: Math.floor(Math.random() * cols),
y: Math.floor(Math.random() * rows)
};
} while (snake.some(seg => seg.x === newFood.x && seg.y === newFood.y));
food = newFood;
}
Event listeners:
window.addEventListener('keydown', (e) => {
// Map keys
let dir = null;
switch(e.key) {
case 'ArrowUp':
case 'w':
case 'W': dir = {x:0, y:-1}; break;
case 'ArrowDown':
case 's':
case 'S': dir = {x:0, y:1}; break;
case 'ArrowLeft':
case 'a':
case 'A': dir = {x:-1, y:0}; break;
case 'ArrowRight':
case 'd':
case 'D': dir = {x:1, y:0}; break;
case ' ':
case 'Enter':
if (gameOver) {
restart();
}
e.preventDefault();
return;
case 'p':
case 'P':
togglePause();
e.preventDefault();
return;
}
if (dir) {
// If overlay is shown and game not started? Actually if overlay "Click or press a key to start", any key starts human mode.
if (overlayText === "Click or press a key to start") {
aiMode = false;
showOverlay(false);
// resume if paused? Not paused.
}
// Add to queue
directionQueue.push(dir);
// Prevent default for arrow keys to avoid scrolling
if (e.key.startsWith('Arrow')) {
e.preventDefault();
}
}
});
Also handle canvas click to start:
canvas.addEventListener('click', () => {
if (overlayText === "Click or press a key to start") {
aiMode = false;
showOverlay(false);
}
});
Restart function:
function restart() {
init(); // resets everything
showOverlay(false);
// start game loop? It's already running.
}
Toggle pause:
function togglePause() {
if (gameOver) return;
paused = !paused;
if (paused) {
overlayText = "Paused (Press P to resume)";
showOverlay(true);
} else {
showOverlay(false);
// reset lastTime? We can adjust timing.
}
}
showOverlay function: we'll have an overlay div or just draw on canvas. Simpler: draw overlay text on canvas in the render function. We'll have a variable overlayText; if not null, draw centered text.
We'll also have a function to draw the overlay.
Now, game loop:
function loop(timestamp) {
if (!lastTime) lastTime = timestamp;
let dt = (timestamp - lastTime) / 1000;
lastTime = timestamp;
// Cap dt to avoid huge steps
if (dt > 0.1) dt = 0.1;
if (!paused && !gameOver) {
update(dt);
}
render();
requestAnimationFrame(loop);
}
Update function:
function update(dt) {
// Accumulate move timer
moveProgress += dt;
// While moveProgress >= moveInterval, process moves? Actually we should process one move per interval. But if dt is large, we might need multiple moves. We can use a while loop.
while (moveProgress >= moveInterval) {
moveProgress -= moveInterval;
processMove();
}
// Update particles
for (let i = particles.length - 1; i >= 0; i--) {
let p = particles[i];
p.x += p.vx * dt;
p.y += p.vy * dt;
p.life -= dt;
if (p.life <= 0) {
particles.splice(i, 1);
}
}
}
processMove():
function processMove() {
if (aiMode) {
// AI decides direction
let aiDir = getAIDirection();
directionQueue.push(aiDir);
}
// Determine direction from queue
let newDirection = direction;
while (directionQueue.length > 0) {
let d = directionQueue.shift();
// Check not opposite
if (!(d.x === -direction.x && d.y === -direction.y)) {
newDirection = d;
break;
}
// else ignore and continue
}
direction = newDirection;
// Compute new head
let head = snake[0];
let newHead = {x: head.x + direction.x, y: head.y + direction.y};
// Check collision
let eating = (newHead.x === food.x && newHead.y === food.y);
let collide = false;
// Wall collision
if (newHead.x < 0 || newHead.x >= cols || newHead.y < 0 || newHead.y >= rows) {
collide = true;
} else {
// Body collision: check against current snake segments, but ignore tail if not eating
for (let i = 0; i < snake.length; i++) {
if (snake[i].x === newHead.x && snake[i].y === newHead.y) {
if (!eating && i === snake.length - 1) {
// tail will be removed, so safe
continue;
}
collide = true;
break;
}
}
}
if (collide) {
gameOver = true;
bestScore = Math.max(bestScore, score);
overlayText = `Game Over! Score: ${score} | Best: ${bestScore} | Press Space or Enter to restart`;
showOverlay(true);
return;
}
// Save old snake
let oldSnake = snake.map(p => ({...p}));
// Update snake
if (eating) {
// prevSnake = [old head, ...oldSnake]
prevSnake = [{...oldSnake[0]}, ...oldSnake];
snake = [newHead, ...oldSnake];
// Increase score
score += 10;
// Generate new food
generateFood();
// Particle burst
createParticles(food.x, food.y);
} else {
// prevSnake = copy of oldSnake
prevSnake = oldSnake;
snake = [newHead, ...oldSnake.slice(0, -1)];
}
// Reset moveProgress? Already subtracted.
moveProgress = 0;
}
Now, AI function:
function getAIDirection() {
// Get possible directions that are not opposite and not immediately deadly
let possible = [];
const dirs = [{x:0,y:-1}, {x:0,y:1}, {x:-1,y:0}, {x:1,y:0}];
for (let d of dirs) {
// Not opposite
if (d.x === -direction.x && d.y === -direction.y) continue;
// Check wall
let nx = snake[0].x + d.x;
let ny = snake[0].y + d.y;
if (nx < 0 || nx >= cols || ny < 0 || ny >= rows) continue;
// Check self collision (ignore tail if not eating? But AI doesn't know if will eat. We'll ignore tail for simplicity)
let collide = false;
for (let i = 0; i < snake.length; i++) {
// If not eating and i is tail, skip? We'll just check all for simplicity, but might get stuck. We'll skip tail.
if (!eating && i === snake.length - 1) continue;
if (snake[i].x === nx && snake[i].y === ny) {
collide = true;
break;
}
}
if (!collide) {
possible.push(d);
}
}
if (possible.length === 0) {
// If no safe move, just pick a direction that is not opposite (might die)
for (let d of dirs) {
if (!(d.x === -direction.x && d.y === -direction.y)) {
return d;
}
}
return direction; // fallback
}
// Choose direction that minimizes Manhattan distance to food
let bestDir = possible[0];
let bestDist = manhattan(bestDir, food);
for (let d of possible) {
let dist = manhattan(d, food);
if (dist < bestDist) {
bestDist = dist;
bestDir = d;
}
}
return bestDir;
}
function manhattan(dir, food) {
let nx = snake[0].x + dir.x;
let ny = snake[0].y + dir.y;
return Math.abs(nx - food.x) + Math.abs(ny - food.y);
}
Note: eating variable in AI is not defined; we'll need to pass it or compute. We can compute if the next position is food. But for simplicity, we can ignore tail collision check in AI or always skip tail. We'll define a local eating flag for AI: let eating = (snake[0].x + d.x === food.x && snake[0].y + d.y === food.y); But we don't know if the move will actually be eating because we haven't moved yet. For collision check, we can treat tail as safe only if not eating. So we can compute if the new position is food. We'll incorporate that.
Modify AI to compute eating for each direction.
Now, render function:
function render() {
// Clear canvas
ctx.fillStyle = '#111';
ctx.fillRect(0, 0, canvas.width, canvas.height);
// Draw grid (optional)
ctx.strokeStyle = '#222';
ctx.lineWidth = 1;
for (let x = 0; x <= cols; x++) {
ctx.beginPath();
ctx.moveTo(x * cellSize, 0);
ctx.lineTo(x * cellSize, rows * cellSize);
ctx.stroke();
}
for (let y = 0; y <= rows; y++) {
ctx.beginPath();
ctx.moveTo(0, y * cellSize);
ctx.lineTo(cols * cellSize, y * cellSize);
ctx.stroke();
}
// Draw food
ctx.fillStyle = '#e74c3c';
ctx.beginPath();
ctx.arc(food.x * cellSize + cellSize/2, food.y * cellSize + cellSize/2, cellSize/2 - 2, 0, Math.PI*2);
ctx.fill();
// Draw particles
for (let p of particles) {
ctx.fillStyle = p.color;
ctx.globalAlpha = p.life / p.maxLife;
ctx.beginPath();
ctx.arc(p.x, p.y, p.size, 0, Math.PI*2);
ctx.fill();
}
ctx.globalAlpha = 1;
// Draw snake with interpolation
// For each segment, interpolate between prevSnake[i] and snake[i]
// If lengths differ, handle gracefully.
let len = snake.length;
for (let i = 0; i < len; i++) {
let prev = prevSnake[i] || snake[i]; // fallback if prevSnake shorter
let cur = snake[i];
// Interpolate based on moveProgress (0 to 1)
let ix = prev.x + (cur.x - prev.x) * moveProgress;
let iy = prev.y + (cur.y - prev.y) * moveProgress;
// Draw segment
if (i === 0) {
// Head
drawHead(ix, iy, direction);
} else {
drawBody(ix, iy);
}
}
// Draw overlay if needed
if (overlayText) {
drawOverlay(overlayText);
}
}
We need to scale interpolated coordinates to pixels: ix * cellSize, iy * cellSize.
Draw head: we'll draw a rounded rectangle or circle with eyes. We'll compute eye positions based on direction.
function drawHead(x, y, dir) {
let px = x * cellSize + cellSize/2;
let py = y * cellSize + cellSize/2;
let radius = cellSize/2 - 1;
// Body
ctx.fillStyle = '#27ae60';
ctx.beginPath();
ctx.arc(px, py, radius, 0, Math.PI*2);
ctx.fill();
// Head darker
ctx.fillStyle = '#1e8449';
ctx.beginPath();
ctx.arc(px, py, radius - 2, 0, Math.PI*2);
ctx.fill();
// Eyes
ctx.fillStyle = '#fff';
let eyeOffset = radius * 0.3;
let eyeDist = radius * 0.5;
// Compute eye positions based on direction
let angle = Math.atan2(dir.y, dir.x); // direction vector
// Left eye offset perpendicular
let perpX = -dir.y;
let perpY = dir.x;
let eye1X = px + Math.cos(angle) * eyeDist * 0.5 + perpX * eyeOffset;
let eye1Y = py + Math.sin(angle) * eyeDist * 0.5 + perpY * eyeOffset;
let eye2X = px + Math.cos(angle) * eyeDist * 0.5 - perpX * eyeOffset;
let eye2Y = py + Math.sin(angle) * eyeDist * 0.5 - perpY * eyeOffset;
ctx.beginPath();
ctx.arc(eye1X, eye1Y, radius * 0.25, 0, Math.PI*2);
ctx.arc(eye2X, eye2Y, radius * 0.25, 0, Math.PI*2);
ctx.fill();
// Pupils
ctx.fillStyle = '#000';
let pupilDist = radius * 0.15;
let pupil1X = eye1X + Math.cos(angle) * pupilDist;
let pupil1Y = eye1Y + Math.sin(angle) * pupilDist;
let pupil2X = eye2X + Math.cos(angle) * pupilDist;
let pupil2Y = eye2Y + Math.sin(angle) * pupilDist;
ctx.beginPath();
ctx.arc(pupil1X, pupil1Y, radius * 0.12, 0, Math.PI*2);
ctx.arc(pupil2X, pupil2Y, radius * 0.12, 0, Math.PI*2);
ctx.fill();
}
Draw body: a slightly lighter green circle.
function drawBody(x, y) {
let px = x * cellSize + cellSize/2;
let py = y * cellSize + cellSize/2;
let radius = cellSize/2 - 1;
ctx.fillStyle = '#2ecc71';
ctx.beginPath();
ctx.arc(px, py, radius, 0, Math.PI*2);
ctx.fill();
// Optional: darker border
ctx.strokeStyle = '#27ae60';
ctx.lineWidth = 1;
ctx.stroke();
}
Create particles:
function createParticles(x, y) {
let px = x * cellSize + cellSize/2;
let py = y * cellSize + cellSize/2;
for (let i = 0; i < 15; i++) {
particles.push({
x: px,
y: py,
vx: (Math.random() - 0.5) * 200,
vy: (Math.random() - 0.5) * 200,
size: Math.random() * 3 + 1,
life: Math.random() * 0.5 + 0.5,
maxLife: 1,
color: ['#f1c40f', '#e67e22', '#e74c3c', '#9b59b6'][Math.floor(Math.random()*4)]
});
}
}
Draw overlay:
function drawOverlay(text) {
ctx.fillStyle = 'rgba(0,0,0,0.7)';
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = '#fff';
ctx.font = '24px Arial';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(text, canvas.width/2, canvas.height/2);
}
Show overlay function: set overlayText and maybe we don't need separate div.
Now, initial start: We'll call init() and then requestAnimationFrame(loop). But we need to show overlay initially. In init, we set overlayText = "Click or press a key to start" and call showOverlay(true) which just sets overlayText. Then render will draw it.
But we also need to start the game loop. So after init, we call requestAnimationFrame(loop).
Now, edge cases: When user presses key to start, we set aiMode = false and overlayText = null (hide overlay). But what if game is already over? We handle restart separately.
We need to ensure that when game over, overlay shows score and restart instructions. Pressing Space/Enter calls restart() which resets game and hides overlay.
Also, when paused, overlay shows paused message. Pressing P again resumes.
Now, about quick successive key presses: We push each keydown to directionQueue. The processMove dequeues one per move. So if you press keys quickly, they queue up. However, if you press keys faster than the snake moves, the queue could grow. That's fine. But we need to ensure that if you press a direction that is opposite of current direction, it's ignored (dequeued and discarded). That's implemented.
But what if you press a direction that is valid, then before the snake moves, you press the opposite direction? The opposite will be queued after the valid one. When processing, the valid direction will be applied, changing direction. Then the opposite direction will be processed on the next move, but now it's opposite of the new direction? Actually, after the first move, direction is changed. The opposite direction might now be valid (not opposite of new direction). For example, snake moving right, you press up then down quickly. Queue: [up, down]. First move: dequeue up, not opposite of right, so direction becomes up. Second move: dequeue down, which is opposite of up, so it's ignored. So the snake goes up then continues up (or maybe down if queue had another). That's correct behavior.
But what if you press right then left quickly? Queue: [right, left]. First move: dequeue right, but current direction is right? Actually initial direction might be right. If current direction is right, right is same direction, not opposite, so direction stays right. Second move: dequeue left, which is opposite of right, so ignored. So snake continues right. That's fine.
Now, we need to ensure that the snake never reverses onto itself. Our check prevents opposite direction from being applied. Also, when we compute new head, we check collision with body. So even if direction somehow is opposite, the snake would collide with itself immediately (since it would try to go back into its own body). But we prevent that by ignoring opposite directions.
Now, about smooth movement: We interpolate between prevSnake and snake. However, after a move, we set moveProgress = 0 and then it accumulates. The interpolation factor is moveProgress (0 to 1). But we reset moveProgress to 0 after processing a move, and then it increases. However, we subtract moveInterval from moveProgress in the while loop, but we set moveProgress = 0 at the end of processMove. Actually, in update, we do:
while (moveProgress >= moveInterval) {
moveProgress -= moveInterval;
processMove();
}
After processing, moveProgress might be > 0 (if we had extra time). But we also set moveProgress = 0 inside processMove? That would lose the extra time. Better to not reset moveProgress to 0 inside processMove, but rather let the while loop handle it. In processMove, we should not set moveProgress = 0. Instead, we just perform the move, and the while loop already subtracted moveInterval. So moveProgress retains the remainder. That's correct.
But we also need to update prevSnake and snake. The interpolation uses moveProgress. After a move, moveProgress is some value between 0 and moveInterval (the remainder). But we want the interpolation to be based on the fraction of the current move interval. So we should compute progress = moveProgress / moveInterval. However, our moveProgress is the time since last move? Actually, we accumulate dt, and when we exceed moveInterval, we subtract moveInterval and do a move. So after subtraction, moveProgress is the remaining time (less than moveInterval). That remaining time is the time since the last move. So the fraction for interpolation is moveProgress / moveInterval. Good.
Thus, in render, we should compute let t = moveProgress / moveInterval; (capped at 1). Then interpolate using t.
We'll define let progress = Math.min(1, moveProgress / moveInterval);
Now, after a move, we set prevSnake to the old positions. Then as time progresses, moveProgress increases, and we interpolate towards the new positions. When moveProgress reaches moveInterval, we perform another move, updating prevSnake again, and the cycle continues.
One issue: When we grow the snake, we set prevSnake = [oldHead, ...oldSnake]. Then snake = [newHead, ...oldSnake]. The interpolation for the new head (index 0) will be from oldHead to newHead. For index 1 (old head), prevSnake[1] is oldSnake[0]? Actually, prevSnake = [oldHead, ...oldSnake]. So prevSnake[1] = oldSnake[0] which is oldHead. Wait, oldSnake[0] is oldHead. So prevSnake[1] = oldHead. But snake[1] = oldHead as well. So interpolation for index 1 is from oldHead to oldHead -> no movement. That's correct because the old head becomes the second segment and stays at its position until the next move. However, in the next move, when we process another move (no growth), prevSnake will be set to oldSnake (which is the current snake before that move). That will correctly shift the previous positions.
So it works.
Now, we need to handle the case where prevSnake length may not match snake length if we didn't set it correctly. We'll ensure they match.
Now, about best score: We'll store in a variable bestScore, initially 0. On game over, we update bestScore = Math.max(bestScore, score). Since we can't use localStorage, it's just for the session.
Now, we need to ensure the canvas is centered and responsive. We'll set CSS:
body {
margin: 0;
padding: 0;
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
background: #222;
font-family: Arial, sans-serif;
}
canvas {
background: #111;
border: 2px solid #444;
box-shadow: 0 0 20px rgba(0,0,0,0.5);
}
But we need to ensure no horizontal scrollbar. The canvas is 400x400, which fits in 1280x800. For small widths (375px), 400px might cause horizontal scroll. We can set max-width: 100% and height auto? But canvas is fixed aspect ratio. We can set width: 100% and height: auto? But canvas width attribute is fixed. We can use CSS to scale: max-width: 100%; height: auto; But the canvas internal resolution is 400x400. We can set style width: 100%; height: auto; but that might distort. Better to set a fixed size that fits in 375px? 400px is too wide. We can reduce cell size or grid size. Requirement: grid of at least 20x20. So minimum 20x20. We can use cell size 18px => 360x360, which fits in 375px. Or we can use 20x20 with cell size 16px => 320x320. Let's choose cellSize = 18, cols=20, rows=20 => 360x360. That fits in 375px with some margin. Or we can keep 20x20 and set canvas max-width: 100% and height: auto, but the aspect ratio is 1:1, so it will scale down. We can set canvas style width: 100%; height: auto; but the internal resolution remains 400x400, and the browser will scale it down, which might look blurry. Better to set a fixed size that is <= 375px. Let's use cellSize = 18, canvas 360x360. That's fine.
We'll set:
const cols = 20;
const rows = 20;
const cellSize = 18;
canvas.width = cols * cellSize; // 360
canvas.height = rows * cellSize; // 360
Then CSS: max-width: 100%; height: auto; but since it's square, it will fit. We'll also set display: block; margin: 0 auto; to center.
Now, we need to ensure the overlay text is readable. We'll adjust font size relative to canvas.
Now, implement createParticles with canvas coordinates.
Now, we need to handle the initial overlay "Click or press a key to start". We'll set overlayText in init. Then in render, if overlayText is not null, draw overlay. But we also need to show overlay when paused. So overlayText can be set to paused message.
Now, we need to handle the case where user clicks canvas to start. We'll add click event listener.
Now, let's write the complete HTML.
We'll structure:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Snake Game</title>
<style>
body { ... }
canvas { ... }
</style>
</head>
<body>
<canvas id="game"></canvas>
<script>
// code
</script>
</body>
</html>
We'll include all JS inline.
Now, let's code carefully.
Potential issues:
- The AI might get stuck in infinite loop if no valid moves. We'll handle by returning a non-opposite direction even if deadly.
- The direction queue might accumulate many opposite directions that are ignored, but we keep dequeuing until find valid. That's fine.
- When game over, we should stop the game loop? We can continue rendering overlay but stop updates. In loop, we check if (gameOver) { render(); requestAnimationFrame(loop); return; } Actually, we still want to render the overlay. So we can still call render() and continue loop, but skip update. That's fine.
- When paused, we skip update but still render.
- We need to ensure that after restart, the game resumes properly. We'll reset variables and hide overlay.
- The initial overlay should be shown before game starts. But we also want the AI to play behind the overlay. So we start the loop, and update runs even with overlay? We'll set aiMode = true, and overlayText = "Click or press a key to start". The update will run, AI moves, but overlay is drawn on top. That's fine. When user clicks, we set aiMode = false and overlayText = null. The game continues.
But we need to ensure that the game doesn't start with a hidden state. The overlay is semi-transparent, so the game is visible underneath.
Now, implement the code.
Let's write step by step.
First, HTML skeleton.
Then CSS.
Then JS.
We'll use strict mode.
We'll define variables at top.
We'll create functions.
Let's write.
After writing, we'll test mentally.
Edge Cases:
- Snake length 1? Initially length 3, but after growth it can be longer. Collision detection with tail: if snake length 1, there is no tail, but our code would treat index 0 as head and also tail? For length 1, snake[0] is head and also tail. In collision check, we check if newHead matches any segment. For length 1, if we are not eating, we would ignore tail (i === snake.length - 1) which is true, so we would ignore collision with itself? That would be bad. But snake length 1 cannot happen because we start with 3 and growth adds length, never reduces below 3. So fine.
- When generating food, ensure it doesn't spawn on snake. We'll use a do-while loop.
- Particles: we'll update in update function.
- Performance: fine.
Now, let's write the code.
We'll include comments.
After finishing, we'll output the code block.