MiniMax M3
by MiniMaxreasoning defaultClean
Running live in a sandboxed frame. Click inside it before using the keyboard.
How it went
- Total time
- 4 min 59 s
- Cost
- $0.046
- Output tokens
- 38,489
- Spent thinking
- 25,390 tokens
- Speed
- 129.3 tok/s
- Console errors
- 0
- First token after
- 1.2 s
- First visible text after
- 4 min 11 s
- Prompt tokens
- 675
- Code size
- 45.9 KB
- Finish reason
- stop
- Served by
- Minimax
- Model id
minimax/minimax-m3- Reasoning sent
model default- Max output
- 512,000 tokens
- Generated
- Nov 13, 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 · 1609 lines
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>Neon Serpent — Snake</title>
<style>
:root {
color-scheme: dark;
--bg: #050912;
--panel: rgba(12, 22, 34, 0.76);
--panel-strong: rgba(8, 17, 28, 0.92);
--line: rgba(157, 226, 218, 0.13);
--text: #edfdf8;
--muted: #829a9e;
--mint: #67f7ba;
--cyan: #35d8d0;
--coral: #ff6676;
--violet: #a99aff;
--amber: #ffd166;
--board-size: min(640px, calc(100vw - 28px), calc(100vh - 196px));
}
* {
box-sizing: border-box;
}
html,
body {
margin: 0;
min-height: 100%;
}
body {
min-height: 100vh;
min-height: 100svh;
overflow-x: hidden;
color: var(--text);
background:
radial-gradient(circle at 16% 10%, rgba(53, 216, 208, 0.13), transparent 31%),
radial-gradient(circle at 88% 86%, rgba(169, 154, 255, 0.11), transparent 33%),
linear-gradient(145deg, #07101b 0%, #040812 52%, #071018 100%);
font-family: Inter, ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
-webkit-font-smoothing: antialiased;
user-select: none;
}
body::before {
content: "";
position: fixed;
inset: 0;
pointer-events: none;
background-image:
linear-gradient(rgba(130, 255, 218, 0.025) 1px, transparent 1px),
linear-gradient(90deg, rgba(130, 255, 218, 0.025) 1px, transparent 1px);
background-size: 42px 42px;
mask-image: radial-gradient(circle at center, black, transparent 78%);
}
button,
canvas {
-webkit-tap-highlight-color: transparent;
}
button {
font: inherit;
}
.app {
position: relative;
z-index: 1;
width: 100%;
max-width: 820px;
min-height: 100vh;
min-height: 100svh;
margin: 0 auto;
padding: 18px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
}
.topbar {
width: var(--board-size);
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
}
.brand {
min-width: 0;
display: flex;
align-items: center;
gap: 12px;
}
.brand-mark {
width: 44px;
height: 44px;
flex: 0 0 auto;
filter: drop-shadow(0 8px 16px rgba(53, 216, 208, 0.18));
}
.brand-copy {
min-width: 0;
}
.brand h1 {
margin: 0;
font-size: clamp(16px, 2vw, 21px);
line-height: 1;
letter-spacing: 0.12em;
font-weight: 800;
white-space: nowrap;
}
.brand p {
margin: 7px 0 0;
color: var(--muted);
font-size: 10px;
line-height: 1;
letter-spacing: 0.17em;
text-transform: uppercase;
white-space: nowrap;
}
.stats {
display: flex;
gap: 9px;
flex: 0 0 auto;
}
.stat {
width: 94px;
height: 58px;
padding: 8px 12px;
display: flex;
flex-direction: column;
justify-content: center;
border: 1px solid var(--line);
border-radius: 15px;
background: linear-gradient(145deg, rgba(18, 32, 45, 0.88), rgba(8, 17, 28, 0.76));
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.035);
backdrop-filter: blur(14px);
}
.stat.current {
border-color: rgba(103, 247, 186, 0.22);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.045),
0 0 22px rgba(53, 216, 208, 0.05);
}
.stat-label {
color: var(--muted);
font-size: 9px;
font-weight: 800;
letter-spacing: 0.16em;
text-transform: uppercase;
}
.stat-value {
margin-top: 2px;
font-size: 25px;
line-height: 1;
font-weight: 800;
letter-spacing: 0.05em;
font-variant-numeric: tabular-nums;
}
.current .stat-value {
color: var(--mint);
text-shadow: 0 0 18px rgba(103, 247, 186, 0.26);
}
.best .stat-value {
color: var(--amber);
text-shadow: 0 0 18px rgba(255, 209, 102, 0.2);
}
.stat-value.pop {
animation: score-pop 0.34s ease-out;
}
@keyframes score-pop {
0% { transform: scale(1); }
38% { transform: scale(1.17); }
100% { transform: scale(1); }
}
.game-shell {
width: var(--board-size);
}
.game-board {
position: relative;
width: 100%;
aspect-ratio: 1 / 1;
overflow: hidden;
isolation: isolate;
border: 1px solid rgba(124, 255, 214, 0.18);
border-radius: 22px;
background: #061413;
box-shadow:
0 35px 90px rgba(0, 0, 0, 0.48),
0 0 0 1px rgba(0, 0, 0, 0.4),
0 0 48px rgba(53, 216, 208, 0.08);
touch-action: none;
transition: border-color 160ms ease, box-shadow 160ms ease;
}
.game-board:focus-within {
border-color: rgba(103, 247, 186, 0.42);
box-shadow:
0 35px 90px rgba(0, 0, 0, 0.48),
0 0 0 1px rgba(103, 247, 186, 0.12),
0 0 54px rgba(53, 216, 208, 0.13);
}
#game {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
outline: none;
}
.board-vignette {
position: absolute;
inset: 0;
z-index: 2;
pointer-events: none;
border-radius: inherit;
background:
radial-gradient(circle at center, transparent 54%, rgba(0, 5, 9, 0.22) 100%),
linear-gradient(90deg, rgba(255, 255, 255, 0.025), transparent 10%, transparent 90%, rgba(255, 255, 255, 0.02));
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.025);
}
.overlay {
position: absolute;
inset: 0;
z-index: 4;
display: grid;
place-items: center;
padding: 16px;
background: rgba(2, 8, 14, 0.22);
opacity: 1;
visibility: visible;
transition: opacity 200ms ease, visibility 200ms ease;
}
.overlay.is-hidden {
opacity: 0;
visibility: hidden;
pointer-events: none;
}
.overlay-card {
width: min(410px, 92%);
max-height: calc(100% - 20px);
overflow: auto;
padding: 27px;
text-align: center;
border: 1px solid rgba(151, 235, 220, 0.18);
border-radius: 20px;
background:
linear-gradient(145deg, rgba(13, 28, 39, 0.91), rgba(5, 13, 23, 0.87)),
var(--panel-strong);
box-shadow:
0 22px 70px rgba(0, 0, 0, 0.52),
inset 0 1px 0 rgba(255, 255, 255, 0.06);
backdrop-filter: blur(14px) saturate(125%);
}
.overlay-kicker {
margin-bottom: 11px;
color: var(--mint);
font-size: 10px;
font-weight: 800;
letter-spacing: 0.2em;
text-transform: uppercase;
}
.overlay[data-kind="gameover"] .overlay-kicker {
color: var(--coral);
}
.overlay[data-kind="paused"] .overlay-kicker {
color: var(--violet);
}
.overlay h2 {
margin: 0;
font-size: clamp(29px, 4vw, 39px);
line-height: 1.02;
letter-spacing: -0.045em;
font-weight: 850;
}
.overlay h2 span {
color: var(--mint);
}
.overlay[data-kind="gameover"] h2 span {
color: var(--coral);
}
.overlay[data-kind="paused"] h2 span {
color: var(--violet);
}
.overlay-copy {
max-width: 310px;
margin: 13px auto 18px;
color: #9bb0b3;
font-size: 13px;
line-height: 1.55;
}
.primary-button {
min-width: 174px;
height: 45px;
padding: 0 18px;
border: 0;
border-radius: 12px;
color: #061712;
background: linear-gradient(135deg, #9affcb, #50eab4);
box-shadow:
0 11px 26px rgba(55, 231, 171, 0.2),
inset 0 1px 0 rgba(255, 255, 255, 0.55);
cursor: pointer;
font-size: 13px;
font-weight: 850;
letter-spacing: 0.025em;
transition: transform 140ms ease, filter 140ms ease, box-shadow 140ms ease;
}
.primary-button:hover {
transform: translateY(-2px);
filter: brightness(1.06);
box-shadow:
0 15px 34px rgba(55, 231, 171, 0.28),
inset 0 1px 0 rgba(255, 255, 255, 0.6);
}
.primary-button:active {
transform: translateY(0) scale(0.98);
}
.primary-button:focus-visible {
outline: 2px solid white;
outline-offset: 3px;
}
.button-key {
margin-left: 9px;
padding: 3px 7px;
border-radius: 6px;
color: #0b4c3b;
background: rgba(3, 52, 37, 0.13);
font-size: 11px;
}
.overlay-hint {
margin: 15px 0 0;
color: #758b8f;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.055em;
}
.overlay-hint::before {
content: "";
display: inline-block;
width: 6px;
height: 6px;
margin-right: 7px;
border-radius: 50%;
background: var(--mint);
box-shadow: 0 0 10px rgba(103, 247, 186, 0.7);
animation: hint-pulse 1.6s ease-in-out infinite;
}
@keyframes hint-pulse {
0%, 100% { opacity: 0.45; transform: scale(0.8); }
50% { opacity: 1; transform: scale(1.08); }
}
.result-grid {
margin: 0 0 17px;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
}
.result-grid[hidden] {
display: none;
}
.result-cell {
padding: 10px 8px;
border: 1px solid rgba(255, 255, 255, 0.07);
border-radius: 11px;
background: rgba(255, 255, 255, 0.035);
}
.result-cell span {
display: block;
color: var(--muted);
font-size: 8px;
font-weight: 800;
letter-spacing: 0.13em;
text-transform: uppercase;
}
.result-cell strong {
display: block;
margin-top: 3px;
font-size: 23px;
font-variant-numeric: tabular-nums;
}
.statusbar {
min-height: 30px;
padding: 7px 3px 0;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
color: #71898d;
font-size: 9px;
font-weight: 750;
letter-spacing: 0.11em;
text-transform: uppercase;
}
.mode-pill {
display: inline-flex;
align-items: center;
gap: 7px;
}
.status-dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--amber);
box-shadow: 0 0 10px rgba(255, 209, 102, 0.65);
}
.mode-pill[data-mode="player"] .status-dot {
background: var(--mint);
box-shadow: 0 0 10px rgba(103, 247, 186, 0.7);
animation: hint-pulse 1.4s ease-in-out infinite;
}
.mode-pill[data-mode="paused"] .status-dot {
background: var(--violet);
box-shadow: 0 0 10px rgba(169, 154, 255, 0.7);
}
.mode-pill[data-mode="gameover"] .status-dot {
background: var(--coral);
box-shadow: 0 0 10px rgba(255, 102, 118, 0.7);
}
.board-meta {
white-space: nowrap;
}
.footer {
width: var(--board-size);
min-height: 26px;
display: flex;
align-items: center;
justify-content: center;
flex-wrap: wrap;
gap: 9px 18px;
color: #667d82;
font-size: 9px;
font-weight: 700;
letter-spacing: 0.035em;
}
.control {
display: inline-flex;
align-items: center;
gap: 6px;
white-space: nowrap;
}
kbd {
min-width: 22px;
height: 20px;
padding: 0 6px;
display: inline-grid;
place-items: center;
border: 1px solid rgba(168, 218, 212, 0.15);
border-bottom-color: rgba(168, 218, 212, 0.25);
border-radius: 6px;
color: #b8ccce;
background: rgba(17, 31, 42, 0.78);
box-shadow: 0 2px 0 rgba(0, 0, 0, 0.25);
font: 700 9px/1 ui-monospace, SFMono-Regular, Menlo, monospace;
}
.key-pair {
display: inline-flex;
gap: 3px;
}
.mobile-only {
display: none;
}
@media (max-width: 520px) {
:root {
--board-size: min(640px, calc(100vw - 24px), calc(100vh - 180px));
}
.app {
padding: 12px;
gap: 10px;
}
.brand {
gap: 8px;
}
.brand-mark {
width: 37px;
height: 37px;
}
.brand h1 {
font-size: 14px;
letter-spacing: 0.095em;
}
.brand p {
display: none;
}
.stats {
gap: 6px;
}
.stat {
width: 67px;
height: 50px;
padding: 7px 9px;
border-radius: 13px;
}
.stat-label {
font-size: 8px;
}
.stat-value {
font-size: 20px;
}
.game-board {
border-radius: 18px;
}
.overlay {
padding: 11px;
}
.overlay-card {
width: min(390px, 96%);
padding: 21px 18px;
border-radius: 17px;
}
.overlay h2 {
font-size: 30px;
}
.overlay-copy {
margin: 10px auto 15px;
font-size: 12px;
}
.overlay-kicker {
margin-bottom: 8px;
font-size: 9px;
}
.overlay-hint {
margin-top: 12px;
font-size: 9px;
}
.result-grid {
margin-bottom: 14px;
}
.result-cell {
padding: 8px 6px;
}
.result-cell strong {
font-size: 20px;
}
.footer {
gap: 8px 12px;
}
.desktop-only {
display: none;
}
.mobile-only {
display: inline;
}
}
@media (max-height: 620px) {
.app {
gap: 8px;
}
.brand p {
display: none;
}
.overlay-copy {
margin: 8px auto 12px;
}
.overlay-hint {
margin-top: 9px;
}
}
@media (prefers-reduced-motion: reduce) {
.overlay,
.primary-button,
.game-board {
transition-duration: 0.01ms;
}
}
</style>
</head>
<body>
<main class="app">
<header class="topbar">
<div class="brand">
<svg class="brand-mark" viewBox="0 0 48 48" aria-hidden="true">
<defs>
<linearGradient id="brandGradient" x1="8" y1="39" x2="39" y2="8" gradientUnits="userSpaceOnUse">
<stop stop-color="#28cfc1"/>
<stop offset="1" stop-color="#9affca"/>
</linearGradient>
</defs>
<rect x="1" y="1" width="46" height="46" rx="14" fill="#0b1b24" stroke="#28525a"/>
<path d="M11 29c0-6 5-8 10-8s8-2 8-6-3-6-7-6" fill="none" stroke="url(#brandGradient)" stroke-width="6" stroke-linecap="round" stroke-linejoin="round"/>
<circle cx="11" cy="29" r="3.2" fill="#d9ffed"/>
<circle cx="10" cy="28" r="0.8" fill="#092019"/>
</svg>
<div class="brand-copy">
<h1>NEON SERPENT</h1>
<p>Precision arcade snake</p>
</div>
</div>
<div class="stats" aria-label="Game scores">
<div class="stat current">
<span class="stat-label">Score</span>
<strong class="stat-value" id="scoreValue">00</strong>
</div>
<div class="stat best">
<span class="stat-label">Best</span>
<strong class="stat-value" id="bestValue">00</strong>
</div>
</div>
</header>
<section class="game-shell" aria-label="Snake game">
<div class="game-board" id="gameBoard">
<canvas id="game" tabindex="0" aria-label="24 by 24 Snake game board"></canvas>
<div class="board-vignette" aria-hidden="true"></div>
<div class="overlay" id="overlay" data-kind="start" role="dialog" aria-modal="true" aria-labelledby="overlayTitle">
<div class="overlay-card">
<div class="overlay-kicker" id="overlayKicker">AI demo · live</div>
<h2 id="overlayTitle">Ready to <span>slither?</span></h2>
<p class="overlay-copy" id="overlayText">An AI serpent is warming up. Take control with the arrow keys or WASD.</p>
<div class="result-grid" id="resultGrid" hidden>
<div class="result-cell">
<span>Final score</span>
<strong id="finalScore">0</strong>
</div>
<div class="result-cell">
<span>Session best</span>
<strong id="finalBest">0</strong>
</div>
</div>
<button class="primary-button" id="primaryButton" type="button">
Start game <span class="button-key">↵</span>
</button>
<p class="overlay-hint" id="overlayHint">Click or press a key to start</p>
</div>
</div>
</div>
<div class="statusbar">
<div class="mode-pill" id="modePill" data-mode="demo">
<span class="status-dot"></span>
<span id="statusText">AI demo</span>
</div>
<span class="board-meta" id="metaText">24 × 24 · len 04</span>
</div>
</section>
<footer class="footer" aria-label="Controls">
<span class="control">
<span class="key-pair"><kbd>W</kbd><kbd>A</kbd><kbd>S</kbd><kbd>D</kbd></span>
<span class="desktop-only">or</span>
<span class="key-pair"><kbd>↑</kbd><kbd>←</kbd><kbd>↓</kbd><kbd>→</kbd></span>
move
</span>
<span class="control"><kbd>P</kbd> pause</span>
<span class="control desktop-only"><kbd>Space</kbd> restart</span>
<span class="control mobile-only">Swipe to steer</span>
</footer>
</main>
<script>
(() => {
"use strict";
const GRID = 24;
const PLAYER_STEP = 118;
const DEMO_STEP = 150;
const MAX_QUEUED_TURNS = 4;
const DIRECTIONS = {
right: { x: 1, y: 0 },
left: { x: -1, y: 0 },
down: { x: 0, y: 1 },
up: { x: 0, y: -1 }
};
const KEY_DIRECTIONS = {
arrowright: DIRECTIONS.right,
arrowleft: DIRECTIONS.left,
arrowdown: DIRECTIONS.down,
arrowup: DIRECTIONS.up,
d: DIRECTIONS.right,
a: DIRECTIONS.left,
s: DIRECTIONS.down,
w: DIRECTIONS.up
};
const canvas = document.getElementById("game");
const ctx = canvas.getContext("2d", { alpha: false });
const gameBoard = document.getElementById("gameBoard");
const overlay = document.getElementById("overlay");
const overlayKicker = document.getElementById("overlayKicker");
const overlayTitle = document.getElementById("overlayTitle");
const overlayText = document.getElementById("overlayText");
const overlayHint = document.getElementById("overlayHint");
const primaryButton = document.getElementById("primaryButton");
const resultGrid = document.getElementById("resultGrid");
const finalScore = document.getElementById("finalScore");
const finalBest = document.getElementById("finalBest");
const scoreValue = document.getElementById("scoreValue");
const bestValue = document.getElementById("bestValue");
const modePill = document.getElementById("modePill");
const statusText = document.getElementById("statusText");
const metaText = document.getElementById("metaText");
let snake = [];
let food = null;
let direction = { x: 1, y: 0 };
let queuedDirections = [];
let score = 0;
let bestScore = 0;
let mode = "demo";
let previousPositions = [];
let animationStarted = performance.now();
let animationDuration = DEMO_STEP;
let frozenProgress = 1;
let lastStep = performance.now();
let lastFrame = lastStep;
let particles = [];
let waves = [];
let floaters = [];
let boardSize = 0;
let cellSize = 0;
let pixelRatio = 1;
let pointerStart = null;
const clamp = (value, min, max) => Math.max(min, Math.min(max, value));
const samePosition = (a, b) => a && b && a.x === b.x && a.y === b.y;
const positionKey = (position) => `${position.x},${position.y}`;
const sameDirection = (a, b) => a.x === b.x && a.y === b.y;
const oppositeDirection = (a, b) => a.x === -b.x && a.y === -b.y;
function resizeCanvas() {
const rect = canvas.getBoundingClientRect();
const size = Math.max(1, Math.round(rect.width));
const nextRatio = Math.min(window.devicePixelRatio || 1, 2);
boardSize = size;
cellSize = size / GRID;
pixelRatio = nextRatio;
canvas.width = Math.round(size * pixelRatio);
canvas.height = Math.round(size * pixelRatio);
ctx.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0);
ctx.imageSmoothingEnabled = true;
}
function spawnFood() {
const occupied = new Set(snake.map(positionKey));
const free = [];
for (let y = 0; y < GRID; y++) {
for (let x = 0; x < GRID; x++) {
if (!occupied.has(`${x},${y}`)) free.push({ x, y });
}
}
if (!free.length) return null;
return free[Math.floor(Math.random() * free.length)];
}
function setStatus(nextMode) {
modePill.dataset.mode = nextMode;
const labels = {
demo: "AI demo",
player: "Live run",
paused: "Paused",
gameover: "Game over"
};
statusText.textContent = labels[nextMode] || nextMode;
}
function pulseValue(element) {
element.classList.remove("pop");
void element.offsetWidth;
element.classList.add("pop");
}
function updateHUD(animateScore = false, animateBest = false) {
scoreValue.textContent = String(score).padStart(2, "0");
bestValue.textContent = String(bestScore).padStart(2, "0");
metaText.textContent = `24 × 24 · len ${String(snake.length).padStart(2, "0")}`;
if (animateScore) pulseValue(scoreValue);
if (animateBest) pulseValue(bestValue);
}
function hideOverlay() {
overlay.classList.add("is-hidden");
overlay.setAttribute("aria-hidden", "true");
}
function showOverlay(kind) {
overlay.dataset.kind = kind;
overlay.classList.remove("is-hidden");
overlay.setAttribute("aria-hidden", "false");
resultGrid.hidden = true;
if (kind === "start") {
overlayKicker.textContent = "AI demo · live";
overlayTitle.innerHTML = "Ready to <span>slither?</span>";
overlayText.textContent = "An AI serpent is warming up. Take control with the arrow keys or WASD.";
primaryButton.innerHTML = 'Start game <span class="button-key">↵</span>';
overlayHint.textContent = "Click or press a key to start";
} else if (kind === "paused") {
overlayKicker.textContent = "Take a breather";
overlayTitle.innerHTML = "Game <span>paused.</span>";
overlayText.textContent = "The grid is frozen. Your run is right where you left it.";
primaryButton.innerHTML = 'Resume <span class="button-key">P</span>';
overlayHint.textContent = "Press P or Space to resume";
} else if (kind === "gameover") {
overlayKicker.textContent = "Run complete";
overlayTitle.innerHTML = "Game <span>over.</span>";
overlayText.textContent = score >= 80
? "Sharp instincts. That was a seriously good run."
: score >= 30
? "A clean run. The grid never stood a chance."
: "The wall wins this round. Ready for another?";
resultGrid.hidden = false;
finalScore.textContent = String(score);
finalBest.textContent = String(bestScore);
primaryButton.innerHTML = 'Play again <span class="button-key">↵</span>';
overlayHint.textContent = "Space or Enter to restart";
}
}
function resetDemo() {
const now = performance.now();
mode = "demo";
score = 0;
queuedDirections = [];
direction = { x: 1, y: 0 };
snake = [
{ x: 8, y: 12 },
{ x: 7, y: 12 },
{ x: 6, y: 12 },
{ x: 5, y: 12 }
];
food = { x: 12, y: 12 };
previousPositions = snake.map((position) => ({ ...position }));
animationStarted = now;
animationDuration = DEMO_STEP;
frozenProgress = 1;
lastStep = now;
setStatus("demo");
updateHUD();
}
function startNewPlayer() {
const now = performance.now();
mode = "player";
score = 0;
queuedDirections = [];
direction = { x: 1, y: 0 };
snake = [
{ x: 6, y: 12 },
{ x: 5, y: 12 },
{ x: 4, y: 12 },
{ x: 3, y: 12 }
];
food = spawnFood();
previousPositions = snake.map((position) => ({ ...position }));
animationStarted = now;
animationDuration = PLAYER_STEP;
frozenProgress = 1;
lastStep = now;
hideOverlay();
setStatus("player");
updateHUD();
canvas.focus({ preventScroll: true });
}
function resumeGame() {
if (mode !== "paused") return;
mode = "player";
animationStarted = performance.now() - frozenProgress * animationDuration;
lastStep = performance.now();
hideOverlay();
setStatus("player");
canvas.focus({ preventScroll: true });
}
function togglePause() {
if (mode === "player") {
frozenProgress = clamp((performance.now() - animationStarted) / animationDuration, 0, 1);
mode = "paused";
showOverlay("paused");
setStatus("paused");
} else if (mode === "paused") {
resumeGame();
}
}
function enqueueDirection(nextDirection) {
const reference = queuedDirections.length
? queuedDirections[queuedDirections.length - 1]
: direction;
if (sameDirection(nextDirection, reference) || oppositeDirection(nextDirection, reference)) {
return false;
}
if (queuedDirections.length >= MAX_QUEUED_TURNS) {
return false;
}
queuedDirections.push({ ...nextDirection });
return true;
}
function findPathLength(start, target, blockedPositions) {
if (samePosition(start, target)) return 0;
const blocked = new Set(blockedPositions.map(positionKey));
blocked.delete(positionKey(start));
const queue = [{ position: start, distance: 0 }];
const visited = new Set([positionKey(start)]);
for (let index = 0; index < queue.length; index++) {
const current = queue[index];
for (const directionOption of Object.values(DIRECTIONS)) {
const next = {
x: current.position.x + directionOption.x,
y: current.position.y + directionOption.y
};
if (next.x < 0 || next.x >= GRID || next.y < 0 || next.y >= GRID) continue;
if (visited.has(positionKey(next))) continue;
if (samePosition(next, target)) return current.distance + 1;
if (blocked.has(positionKey(next))) continue;
visited.add(positionKey(next));
queue.push({ position: next, distance: current.distance + 1 });
}
}
return null;
}
function countReachable(start, blockedPositions) {
const blocked = new Set(blockedPositions.map(positionKey));
blocked.delete(positionKey(start));
const queue = [start];
const visited = new Set([positionKey(start)]);
let count = 1;
for (let index = 0; index < queue.length; index++) {
const current = queue[index];
for (const directionOption of Object.values(DIRECTIONS)) {
const next = {
x: current.x + directionOption.x,
y: current.y + directionOption.y
};
if (next.x < 0 || next.x >= GRID || next.y < 0 || next.y >= GRID) continue;
if (blocked.has(positionKey(next))) continue;
if (visited.has(positionKey(next))) continue;
visited.add(positionKey(next));
queue.push(next);
count++;
}
}
return count;
}
function chooseAIDirection() {
const options = [DIRECTIONS.right, DIRECTIONS.down, DIRECTIONS.left, DIRECTIONS.up];
let bestDirection = direction;
let bestValue = -Infinity;
for (const option of options) {
if (oppositeDirection(option, direction)) continue;
const next = {
x: snake[0].x + option.x,
y: snake[0].y + option.y
};
if (next.x < 0 || next.x >= GRID || next.y < 0 || next.y >= GRID) continue;
const eating = samePosition(next, food);
const collisionBody = eating ? snake : snake.slice(0, -1);
if (collisionBody.some((segment) => samePosition(segment, next))) continue;
const bodyAfterMove = [next, ...snake];
if (!eating) bodyAfterMove.pop();
const pathLength = findPathLength(next, food, bodyAfterMove);
const reachableCells = countReachable(next, bodyAfterMove);
const distance = Math.abs(next.x - food.x) + Math.abs(next.y - food.y);
const pathValue = pathLength === null ? 0 : 1200 - pathLength * 22;
const value = pathValue + reachableCells * 2.2 - distance * 0.15;
if (value > bestValue) {
bestValue = value;
bestDirection = option;
}
}
return bestDirection;
}
function spawnFoodBurst(gridPosition) {
const x = (gridPosition.x + 0.5) * cellSize;
const y = (gridPosition.y + 0.5) * cellSize;
const colors = ["#7affc1", "#42e6d1", "#fff1a8", "#ff8180"];
for (let index = 0; index < 19; index++) {
const angle = Math.random() * Math.PI * 2;
const speed = cellSize * (1.1 + Math.random() * 2.5);
const life = 0.42 + Math.random() * 0.42;
particles.push({
x,
y,
vx: Math.cos(angle) * speed,
vy: Math.sin(angle) * speed,
size: cellSize * (0.035 + Math.random() * 0.05),
life,
maxLife: life,
color: colors[Math.floor(Math.random() * colors.length)]
});
}
waves.push({ x, y, life: 0.55, maxLife: 0.55 });
floaters.push({ x, y: y - cellSize * 0.2, text: "+10", life: 0.9, maxLife: 0.9 });
}
function spawnCrashBurst(gridPosition) {
const x = (gridPosition.x + 0.5) * cellSize;
const y = (gridPosition.y + 0.5) * cellSize;
for (let index = 0; index < 25; index++) {
const angle = Math.random() * Math.PI * 2;
const speed = cellSize * (0.8 + Math.random() * 3.2);
const life = 0.45 + Math.random() * 0.55;
particles.push({
x,
y,
vx: Math.cos(angle) * speed,
vy: Math.sin(angle) * speed,
size: cellSize * (0.035 + Math.random() * 0.06),
life,
maxLife: life,
color: index % 3 === 0 ? "#fff0a8" : "#ff6676"
});
}
waves.push({ x, y, life: 0.65, maxLife: 0.65 });
}
function updateEffects(deltaTime) {
for (const particle of particles) {
particle.life -= deltaTime;
particle.x += particle.vx * deltaTime;
particle.y += particle.vy * deltaTime;
particle.vy += cellSize * 1.1 * deltaTime;
particle.vx *= 1 - deltaTime * 0.7;
}
particles = particles.filter((particle) => particle.life > 0);
for (const wave of waves) wave.life -= deltaTime;
waves = waves.filter((wave) => wave.life > 0);
for (const floater of floaters) {
floater.life -= deltaTime;
floater.y -= cellSize * 1.2 * deltaTime;
}
floaters = floaters.filter((floater) => floater.life > 0);
}
function endGame() {
mode = "gameover";
queuedDirections = [];
if (score > bestScore) bestScore = score;
spawnCrashBurst(snake[0]);
setStatus("gameover");
updateHUD(false, score === bestScore);
showOverlay("gameover");
}
function performStep() {
const now = performance.now();
let nextDirection = direction;
if (mode === "player") {
if (queuedDirections.length) nextDirection = queuedDirections.shift();
} else {
nextDirection = chooseAIDirection();
}
direction = { ...nextDirection };
const nextHead = {
x: snake[0].x + direction.x,
y: snake[0].y + direction.y
};
const hitsWall = nextHead.x < 0 || nextHead.x >= GRID || nextHead.y < 0 || nextHead.y >= GRID;
const willEat = samePosition(nextHead, food);
const collisionBody = willEat ? snake : snake.slice(0, -1);
const hitsSelf = collisionBody.some((segment) => samePosition(segment, nextHead));
if (hitsWall || hitsSelf) {
if (mode === "demo") {
resetDemo();
} else {
endGame();
}
return;
}
const oldPositions = snake.map((position) => ({ ...position }));
snake = [nextHead, ...snake];
if (!willEat) snake.pop();
previousPositions = oldPositions;
animationStarted = now;
animationDuration = mode === "demo" ? DEMO_STEP : PLAYER_STEP;
if (willEat) {
const eatenFood = food;
score += 10;
const improved = score > bestScore;
if (improved) bestScore = score;
food = spawnFood();
spawnFoodBurst(eatenFood);
updateHUD(true, improved);
}
}
function traceSnakePath(points) {
ctx.beginPath();
ctx.moveTo(points[points.length - 1].x, points[points.length - 1].y);
for (let index = points.length - 2; index >= 0; index--) {
ctx.lineTo(points[index].x, points[index].y);
}
}
function getRenderPoints(now) {
let progress = 1;
if (mode === "paused") {
progress = frozenProgress;
} else if (mode !== "gameover") {
progress = clamp((now - animationStarted) / animationDuration, 0, 1);
}
return snake.map((position, index) => {
const from = previousPositions[index] || position;
return {
x: (from.x + (position.x - from.x) * progress + 0.5) * cellSize,
y: (from.y + (position.y - from.y) * progress + 0.5) * cellSize
};
});
}
function drawBackground() {
const background = ctx.createLinearGradient(0, 0, boardSize, boardSize);
background.addColorStop(0, "#071918");
background.addColorStop(0.55, "#061311");
background.addColorStop(1, "#07151a");
ctx.fillStyle = background;
ctx.fillRect(0, 0, boardSize, boardSize);
const glow = ctx.createRadialGradient(
boardSize * 0.46,
boardSize * 0.42,
0,
boardSize * 0.46,
boardSize * 0.42,
boardSize * 0.72
);
glow.addColorStop(0, "rgba(40, 142, 120, 0.10)");
glow.addColorStop(1, "rgba(0, 0, 0, 0)");
ctx.fillStyle = glow;
ctx.fillRect(0, 0, boardSize, boardSize);
ctx.fillStyle = "rgba(166, 255, 223, 0.014)";
for (let y = 0; y < GRID; y++) {
for (let x = 0; x < GRID; x++) {
if ((x + y) % 2 === 0) {
ctx.fillRect(x * cellSize, y * cellSize, cellSize, cellSize);
}
}
}
ctx.beginPath();
for (let index = 0; index <= GRID; index++) {
const position = Math.round(index * cellSize) + 0.5;
ctx.moveTo(position, 0);
ctx.lineTo(position, boardSize);
ctx.moveTo(0, position);
ctx.lineTo(boardSize, position);
}
ctx.strokeStyle = "rgba(129, 241, 211, 0.065)";
ctx.lineWidth = 0.75;
ctx.stroke();
ctx.strokeStyle = "rgba(131, 255, 216, 0.11)";
ctx.lineWidth = 1;
ctx.strokeRect(0.5, 0.5, boardSize - 1, boardSize - 1);
}
function drawFood(now) {
if (!food) return;
const x = (food.x + 0.5) * cellSize;
const y = (food.y + 0.5) * cellSize;
const pulse = 1 + Math.sin(now / 210) * 0.09;
const radius = cellSize * 0.245 * pulse;
ctx.save();
ctx.shadowColor = "rgba(255, 86, 105, 0.85)";
ctx.shadowBlur = cellSize * 0.65;
const glow = ctx.createRadialGradient(x - radius * 0.35, y - radius * 0.4, radius * 0.08, x, y, radius * 1.25);
glow.addColorStop(0, "#ffe0a3");
glow.addColorStop(0.35, "#ff8b72");
glow.addColorStop(1, "#f34668");
ctx.fillStyle = glow;
ctx.beginPath();
ctx.arc(x, y, radius, 0, Math.PI * 2);
ctx.fill();
ctx.shadowBlur = 0;
ctx.strokeStyle = "rgba(255, 190, 165, 0.34)";
ctx.lineWidth = Math.max(1, cellSize * 0.035);
ctx.beginPath();
ctx.arc(x, y, radius * 1.48, 0, Math.PI * 2);
ctx.stroke();
ctx.fillStyle = "rgba(255, 255, 255, 0.72)";
ctx.beginPath();
ctx.arc(x - radius * 0.33, y - radius * 0.37, radius * 0.2, 0, Math.PI * 2);
ctx.fill();
const sparkleSize = cellSize * 0.075;
ctx.strokeStyle = "rgba(255, 225, 170, 0.72)";
ctx.lineWidth = Math.max(1, cellSize * 0.025);
ctx.beginPath();
ctx.moveTo(x + radius * 1.5, y - radius * 1.1);
ctx.lineTo(x + radius * 1.5, y - radius * 1.1 - sparkleSize);
ctx.moveTo(x + radius * 1.5 - sparkleSize * 0.55, y - radius * 1.1 - sparkleSize * 0.55);
ctx.lineTo(x + radius * 1.5 + sparkleSize * 0.55, y - radius * 1.1 - sparkleSize * 0.55);
ctx.stroke();
ctx.restore();
}
function drawSnake(now) {
const points = getRenderPoints(now);
if (!points.length) return;
ctx.save();
ctx.lineCap = "round";
ctx.lineJoin = "round";
traceSnakePath(points);
ctx.strokeStyle = "rgba(1, 35, 34, 0.95)";
ctx.lineWidth = cellSize * 0.82;
ctx.shadowColor = "rgba(44, 230, 180, 0.25)";
ctx.shadowBlur = cellSize * 0.42;
ctx.stroke();
const bodyGradient = ctx.createLinearGradient(0, boardSize, boardSize, 0);
bodyGradient.addColorStop(0, "#1aa99c");
bodyGradient.addColorStop(0.55, "#3ed8ad");
bodyGradient.addColorStop(1, "#99ffca");
traceSnakePath(points);
ctx.shadowBlur = 0;
ctx.strokeStyle = bodyGradient;
ctx.lineWidth = cellSize * 0.64;
ctx.stroke();
traceSnakePath(points);
ctx.strokeStyle = "rgba(230, 255, 239, 0.2)";
ctx.lineWidth = cellSize * 0.1;
ctx.stroke();
for (let index = 1; index < points.length - 1; index += 2) {
ctx.fillStyle = "rgba(4, 95, 79, 0.28)";
ctx.beginPath();
ctx.arc(points[index].x, points[index].y, cellSize * 0.065, 0, Math.PI * 2);
ctx.fill();
}
const head = points[0];
const headRadius = cellSize * 0.39;
const headGradient = ctx.createRadialGradient(
head.x - cellSize * 0.12,
head.y - cellSize * 0.14,
cellSize * 0.04,
head.x,
head.y,
cellSize * 0.5
);
headGradient.addColorStop(0, "#d8ffea");
headGradient.addColorStop(0.4, "#7dffc3");
headGradient.addColorStop(1, "#20b99c");
ctx.shadowColor = "rgba(103, 247, 186, 0.62)";
ctx.shadowBlur = cellSize * 0.5;
ctx.fillStyle = headGradient;
ctx.beginPath();
ctx.arc(head.x, head.y, headRadius, 0, Math.PI * 2);
ctx.fill();
ctx.shadowBlur = 0;
ctx.strokeStyle = "rgba(4, 70, 58, 0.8)";
ctx.lineWidth = Math.max(1, cellSize * 0.045);
ctx.stroke();
const perpendicular = { x: -direction.y, y: direction.x };
for (const side of [-1, 1]) {
const eyeX = head.x + direction.x * cellSize * 0.12 + perpendicular.x * side * cellSize * 0.15;
const eyeY = head.y + direction.y * cellSize * 0.12 + perpendicular.y * side * cellSize * 0.15;
ctx.fillStyle = "#f4fff9";
ctx.beginPath();
ctx.arc(eyeX, eyeY, cellSize * 0.105, 0, Math.PI * 2);
ctx.fill();
const pupilX = eyeX + direction.x * cellSize * 0.035;
const pupilY = eyeY + direction.y * cellSize * 0.035;
ctx.fillStyle = "#07171b";
ctx.beginPath();
ctx.arc(pupilX, pupilY, cellSize * 0.052, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = "rgba(255, 255, 255, 0.9)";
ctx.beginPath();
ctx.arc(pupilX - cellSize * 0.015, pupilY - cellSize * 0.018, cellSize * 0.014, 0, Math.PI * 2);
ctx.fill();
}
ctx.restore();
}
function drawEffects() {
ctx.save();
ctx.globalCompositeOperation = "lighter";
for (const wave of waves) {
const progress = 1 - wave.life / wave.maxLife;
ctx.globalAlpha = Math.max(0, wave.life / wave.maxLife) * 0.72;
ctx.strokeStyle = "#8affca";
ctx.lineWidth = Math.max(1, cellSize * 0.045);
ctx.beginPath();
ctx.arc(wave.x, wave.y, cellSize * (0.2 + progress * 1.25), 0, Math.PI * 2);
ctx.stroke();
}
for (const particle of particles) {
ctx.globalAlpha = Math.max(0, particle.life / particle.maxLife);
ctx.fillStyle = particle.color;
ctx.beginPath();
ctx.arc(particle.x, particle.y, particle.size, 0, Math.PI * 2);
ctx.fill();
}
ctx.restore();
for (const floater of floaters) {
const alpha = Math.max(0, floater.life / floater.maxLife);
ctx.save();
ctx.globalAlpha = alpha;
ctx.font = `800 ${Math.max(13, cellSize * 0.43)}px ui-sans-serif, system-ui, sans-serif`;
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.lineWidth = Math.max(2, cellSize * 0.08);
ctx.strokeStyle = "rgba(3, 20, 18, 0.8)";
ctx.strokeText(floater.text, floater.x, floater.y);
ctx.fillStyle = "#baffd9";
ctx.fillText(floater.text, floater.x, floater.y);
ctx.restore();
}
}
function draw(now) {
if (!boardSize) return;
drawBackground();
drawFood(now);
drawSnake(now);
drawEffects();
}
function frame(now) {
const deltaTime = Math.min(0.05, Math.max(0, (now - lastFrame) / 1000));
lastFrame = now;
if (mode !== "paused") updateEffects(deltaTime);
if (mode === "demo" || mode === "player") {
const stepDuration = mode === "demo" ? DEMO_STEP : PLAYER_STEP;
if (now - lastStep >= stepDuration) {
lastStep = now;
performStep();
}
}
draw(now);
requestAnimationFrame(frame);
}
function handleKey(event) {
if (event.metaKey || event.ctrlKey || event.altKey) return;
const key = String(event.key).toLowerCase();
const requestedDirection = KEY_DIRECTIONS[key];
const activationKey = key === " " || key === "enter";
if (mode === "demo") {
if (key === "tab") return;
if (requestedDirection || activationKey) event.preventDefault();
startNewPlayer();
if (requestedDirection) enqueueDirection(requestedDirection);
return;
}
if (mode === "gameover") {
if (activationKey) {
event.preventDefault();
startNewPlayer();
}
return;
}
if (mode === "paused") {
if (key === "p" || activationKey) {
event.preventDefault();
resumeGame();
}
return;
}
if (mode === "player") {
if (requestedDirection) {
event.preventDefault();
enqueueDirection(requestedDirection);
} else if (key === "p" && !event.repeat) {
event.preventDefault();
togglePause();
} else if (activationKey) {
event.preventDefault();
}
}
}
primaryButton.addEventListener("click", (event) => {
event.stopPropagation();
if (mode === "paused") {
resumeGame();
} else if (mode === "demo" || mode === "gameover") {
startNewPlayer();
}
});
gameBoard.addEventListener("pointerdown", (event) => {
if (event.target.closest("button")) return;
if (mode === "demo" || mode === "gameover") {
event.preventDefault();
startNewPlayer();
} else if (mode === "paused") {
resumeGame();
} else if (mode === "player") {
pointerStart = { x: event.clientX, y: event.clientY };
try {
gameBoard.setPointerCapture(event.pointerId);
} catch (_) {
// Pointer capture is an enhancement; keyboard controls remain available.
}
}
canvas.focus({ preventScroll: true });
});
gameBoard.addEventListener("pointerup", (event) => {
if (!pointerStart || mode !== "player") return;
const deltaX = event.clientX - pointerStart.x;
const deltaY = event.clientY - pointerStart.y;
pointerStart = null;
if (Math.max(Math.abs(deltaX), Math.abs(deltaY)) < 18) return;
if (Math.abs(deltaX) > Math.abs(deltaY)) {
enqueueDirection(deltaX > 0 ? DIRECTIONS.right : DIRECTIONS.left);
} else {
enqueueDirection(deltaY > 0 ? DIRECTIONS.down : DIRECTIONS.up);
}
});
gameBoard.addEventListener("pointercancel", () => {
pointerStart = null;
});
gameBoard.addEventListener("contextmenu", (event) => event.preventDefault());
window.addEventListener("keydown", handleKey, { passive: false });
if ("ResizeObserver" in window) {
const observer = new ResizeObserver(resizeCanvas);
observer.observe(gameBoard);
} else {
window.addEventListener("resize", resizeCanvas);
}
resetDemo();
showOverlay("start");
resizeCanvas();
requestAnimationFrame(frame);
})();
</script>
</body>
</html>Reasoning trace (25,390 tokens)
We need produce exactly one fenced html complete. Need design polished Snake. Need heed game starts self-playing demo behind message? Developer says games must start self-playing demo mode behind Click or press key to start, simple AI plays. User asks controls. Need implement. Canvas grid at least 20x20 centered. Responsive down 375 no horiz. Smooth movement, distinct head eyes facing direction, particle burst. Score session best (in-memory variable, no storage). Input queue honoring quick presses no reverse. Pause P. Game over overlay score; Space/Enter restarts. Start message. After initial click/key, perhaps transition to player mode? "self-playing demo mode (a simple AI plays) behind a 'Click or press a key to start' message" Means likely initial screen with message and AI demo visibly moving behind it; click starts player. Need ensure first paint meaningful, game in action by 3 sec. Maybe AI runs from outset behind center start panel. Click/key dismisses panel and player controls. Arrow/WASD input only after click; keyboard only iframe receives after click anyway. But perhaps clicking canvas starts and that same click shouldn't accidentally control. Message.
Could make polished UI around canvas, title, score cards, controls. 24x24 grid. Canvas logical maybe 576 square, CSS responsive. At 1280x800 centered page. Need fit. Header etc. Maybe canvas 600x600, grid 24 => cell25. Page height 800. Header 80, main 600, footer maybe 30. Works 780 perhaps. Use canvas CSS width min(72vh, calc(100vw - 40px), 640px), aspect. Center. Grid 24x24. At 375 viewport, body title etc, canvas width ~335, controls wrap. Need no horiz.
Visual aesthetic: deep navy background, glass panel, neon emerald snake, coral food, subtle grid. Canvas drawing high DPI? We can set actual canvas 960x960 maybe CSS 600 and scale context? Better use fixed logical 600 and set canvas width=1200 height=1200 DPR 2? Drawing coordinate logical. Could resize dynamically based CSS pixels and DPR. Need maintain square and grid. Simpler canvas width=640 height=640, CSS max width. Crisp at normal; device ratio perhaps canvas internal 1280 and ctx scale(2). But resize responsive can create blurry if fixed. Use ResizeObserver to set width = rect.width*dpr and set transform dpr; drawing coordinate size variable. Grid cell = size/N. Good.
Smooth movement: snake discrete positions but interpolation over step duration. Need render between grid positions. Each segment represented grid coords; when moving, body positions should follow path. A canonical smooth snake: snake array of grid cells, each move shifts; render interpolated head from prior head to current. But body segment positions also shift from prior positions to current with stagger maybe. We can render each segment using previous snake positions offset. To maintain smooth continuous body, use path based on current snake list plus interpolate leading edge? Common rendering: snake array includes head current grid, upon move shift. Draw each segment at interpolation from old positions to new. At t from 0 to1, head moves from old head to new head; segment i moves from old snake[i] to new snake[i]. Since after update snake = [newHead,...oldSnake sliced], old segment i+1 = new segment i, so body advances. At t0 new segment positions correspond old next; creates movement. For each segment index i, from oldSnake[i] maybe: head from old[0] to new[0]; i=1 from old[1]? This would lag weird. Better old state positions list; new list. Draw each segment interpolation from oldSnake[i] to newSnake[i]. At t=0 after update segment0 old head, segment1 old neck; at t=1 all new positions. Yes body traverses. But new segment i equals old segment i+1, so movement smooth. Last tail new equals old last (no growth), so tail static until grows; okay. Draw rounded connected segments. If interpolating each segment independently rounded rect, spacing smooth but overlaps. Distinct head with eyes.
Could maintain continuous waypoint history to produce more genuinely smooth path, but interpolation enough. At each tick update state and animation elapsed. Draw in rAF. Need timing based performance. snake moves step every say 115ms in player, AI maybe 150ms. Smooth interpolation duration equal step interval. Need particles.
Input queue: maintain directionQueue with max maybe 2/3. Each input relative to last queued direction to prevent reversal and successive orthogonal accepted. On keydown Arrow/WASD: if game mode demo, start player? Requirement "Click or press a key to start". Press any key could start but if arrow then maybe input it. We'll have keydown starts, then input direction. directionQueue. Prevent default. At most 2 queued to honor quick presses. Validate no reversal against current effective direction. "Quick successive key presses must both be honored" if press up then left quickly, both need honored. Queue allows. If opposite invalid ignore. Need avoid same direction duplicates. If current up then right then down, queue [right,down], both honored. Good. If up then down (reverse), ignore down because against last queued up. Fine.
Game over when wall/self. Need game over overlay canvas or HTML. User explicitly overlay showing score. We can do overlay div positioned over board with score, button maybe. Space/Enter restarts. P pauses. Overlay initial start message with CTA. Game over UI includes score and "Play again". Maybe control text. Pause overlay. Click board focuses and starts. Keyboard event document/window. iframe only receives keyboard after click. Click on start overlay can start player.
AI demo: simple AI that chooses safe direction, ideally seeks food but avoids immediate collision. Need visible action behind overlay. Implement chooseAIDirection checks candidates not reverse and safe. Score perhaps food generated and eaten. Could AI game over eventually; restart demo automatically? The overlay initial remains until user start. If AI hits wall/self, reset demo and continue. We can implement robust lookahead to avoid. Since behind overlay maybe board dimmed but still motion/food. Overlay center translucent so visible.
Session best score: variable bestScore; update when score increases; maybe display both. Demo scores could raise best before player starts. "best score of session" okay. But perhaps player best should be separate? Best of session can include demo. Better maintain bestScore across both. Initial 0.
UI polished. Need single HTML inline. No external fonts. Use system font. Maybe font face no network. Use CSS. Canvas board with shadow, gradient background. HTML layout:
<body>
<div class="app">
<header class="topbar">
<div brand><div logo maybe inline SVG? No external; could use CSS snake icon. title "NEON SERPENT", subtitle...
<div class=stats><div stat score><span>SCORE</span><strong id=score>000</strong> etc Best.
</header>
<main class=game-shell>
<div class=board-frame>
<canvas id=game width=... aria-label>
<div class=scanlines maybe>
<div class=overlay id=overlay>
<div class=eyebrow>ARCADE // 24 × 24</div>
<div icon maybe CSS>
<h1 id=overlayTitle>Ready to slither?</h1>
<p id=overlayText>Watch the demo, then take the reins...</p>
<button id=overlayButton>Start game <kbd>Enter</kbd></button>
<div class=hint>Click or press a key to start</div>
</div>
</div>
<div class=controls-row perhaps left status LIVE / Demo, right keys>
</main>
<footer>...</footer>
</div>
</body>
At 1280 x800, board-frame maybe width min(640px, calc(100vh - 190px)). Header 70, margins 20, footer 35 = total 785. Set board size clamp? CSS --board-size: min(640px, calc(100vw - 40px), calc(100vh - 190px)); At 800 gives 610; nice. At 375 width gives335; calc vh huge, width 335. Body min-height 100svh. app gap 14. Header may wrap? At 375 title + stats; title width and stats. Stats cards 2x maybe. Better topbar display flex, brand, stats. At 375 brand title 16 and stats cards 56 each maybe fits 335: brand 145 + gap + stats 128 = 285. okay. Score digits. header maybe 64 high. Board 335. Footer controls can wrap, app total 64+14+335+12+50+ paddings 24 =499. Fine.
At desktop board centered. body background radial gradients and grid pattern.
Canvas drawing:
- clear gradient dark #071a18 perhaps.
- grid lines #153a34 low alpha.
- border.
- food: glowing red/orange orb with pulse; maybe small star. Eating particles.
- snake: body segments in lime/teal gradient, connected. We need draw body smooth.
Could draw rounded capsules connecting consecutive interpolated positions. Segment coordinates from interpolated old/new arrays. Draw line connecting centers with lineCap round, lineWidth cell*0.72, shadow blur. But body consists path center points. If moving turns, connecting current interpolated points could produce odd diagonal across corner (old/new interpolation). Let's examine snake data and interpolation. Each segment position interpolation from old segment to new segment. The current head interpolation from old head to new. Neck new position = old head at t=0, but segment1 interpolates old neck to old head. Thus path between head interpolated and neck interpolated moves smoothly. At turn 90°, segment paths interpolate diagonally between adjacent grid points causing a rounded corner. Good. Draw body as stroke line connecting points, line join round. But growth: new segment index equals old index+1 except last new is duplicate? Let's define snake list head first. On non-grow move newSnake=[newHead,...snake]; length same. old/new relation new[i]=old[i-1] for i>=1. Interpolation each i from old[i] to new[i]. At t=0:
head old0
seg1 old1 -> target old0
seg2 old2 -> target old1
At t=.5 positions each halfway toward predecessor. There might be gaps? segment0 halfway old0->new head, seg1 halfway old1->old0. Difference about half grid each, okay.
At t=1: head new0, seg1 old0, etc.
Then next move old = new, works. Draw line path. Tail endpoint. Body stroke one color. Then overlay segment scales/gradients maybe each body segment circle at interpolated coordinate. Distinct head.
Direction and eyes: head position current interpolated. Direction is movement from old head to new head; at t maybe. We know moveDir from tick. Draw head oriented. Could draw rounded rectangle or circle. Use ellipse? Distinct head circle radius .4 cell. Eyes based on movement direction vector. Put two eyes toward front. Perp offsets. White eyes with dark pupils. Need head perhaps slightly larger.
If we draw body path first and head circle, eyes. Direction vector. At start direction right. Use dx/dy based moveDir. Eye centers head + dir*0.12cell +/- perp*0.16cell. pupils + dir*.04. White sclera circles radius .09 cell, pupils .045. Draw highlights.
Food generated random on free cells. Draw at cell center with pulse; red/pink circle radius .28 cell plus glow and maybe little diamond/rays. Keep food object grid, pulse from time.
Particles: list {x,y,vx,vy,life,maxLife,color,size}. Spawn 14 at food. Update per frame. Draw additive glow. Also score pop text maybe particles or floating text. Add floatingTexts.
Game state:
const GRID=24; let snake, direction, queuedDirections, food, score, bestScore=0, mode='demo' maybe 'demo','player','gameover','paused'.
Initial overlay "Click or press a key to start", demo behind. Could status badge "DEMO". Press/click starts mode player, resets? We could retain snake from demo at current location but easier reset game. Start button "Play now". Requirement click or press key start. Any key starts. On click overlay start. Button click starts. Need event propagation.
Timing:
let lastStepTime = performance.now(), stepDuration = mode player? 125 : 165; maybe AI.
rAF loop:
const now; dt = min((now-lastFrame)/1000, .05); update particles; if mode demo/player and now-lastStep>=stepDuration:
perhaps while elapsed >= stepDuration perform move, but don't allow huge. We can one or multiple. Since rAF frequent, simple.
update direction for AI immediately before move.
move:
const dir = mode player ? consume queue : AI
Determine next = snake[0]+dir.
If collision wall or body -> endGame. For growth check food equality. Need collision self with tail if moving into tail and no food: tail will move, legal. Check snake.slice(0, snake.length - (willGrow?0:1)). includes next. If growth food, body remains tail so include all. If collision -> end.
Set oldSnake = snake.map copy. snake = new list. direction=dir. If eat score +10; spawnFood; spawn particles; update best; maybe floating +10.
Then animation start? We can store prevPositions = old and animStart = now? If multiple steps, use. Usually no lag. Set moveFrom = old, moveTo = new, animationStart = performance.now(), animationDuration = stepDuration. Draw interpolation clamp((now-animationStart)/duration). At initial set no movement and static.
Need account AI moves: AI chooses direction based on food, avoiding. We can make simple robust:
function chooseAIDirection(snake, food):
candidates directions excluding reverse and boundaries.
score each: distance to food, plus safety.
Safety simulate next and check collision against snake excluding tail; count free spaces after move via BFS maybe.
Ideally avoids trapping. Could implement one-step safe and prefer food path. For demo visual enough.
candidates order sorted by manhattan distance, but direct toward food can hit wall. Filter safe. For each safe compute path length to food with BFS on occupancy but food considered target; no walls. occupancy includes snake excluding tail maybe. Then score reachable food 1000 - path*10 - distance. If no path choose max free area. Implement flood fill for reachable free cells after candidate? Simpler:
function isSafeCandidate(dir): next in bounds and not in body excluding tail if not food.
function reachableSpace(nextPos, bodyAfter): BFS up to grid, avoids body. Count. Food path separately.
Could write snakeAI:
candidates not reverse, next.
if out or collides (body excluding moving tail) invalid.
For each, bodyAfter = [next,...snake]; tail pops unless food.
path BFS from next to food avoiding bodyAfter, max maybe. Return path len or Infinity.
safe area BFS from next avoiding bodyAfter.
score = path finite ? 2000-path*10 : 0 + area*2 - manhattan*0.1. Choose max.
If all invalid choose reverse maybe game over; AI likely.
Food could be behind body, still.
New food spawn on free cells. Need ensure. list all cells excluding snake; random. If no free (unlikely) null.
Collision rendering overlay. Game over overlay in HTML with panel. Mode gameover stops snake. We can update overlay title, text score. Space/Enter restart. P pauses. In pause mode overlay showing "Paused", maybe resume on P or any? Requirement P pauses. Use P toggles pause. Space/Enter restarts only game over; maybe in paused, P resumes. Do not let Space trigger start in initial overlay? Developer says click or press key to start, so any key could start. But Enter/Space definitely. We can start with any key. Yet if pressing P in initial maybe starts player rather than pause. fine.
When player starts, reset score to 0? likely. "current score and best score of session". If demo ate food and best gets score; then starting player reset current but best remains demo best. Could separate demoScore and player score; best maybe max of scores. Demo could inflate best artificially. Better current score displayed as 0 upon player start; best score session includes demo? Maybe best should be player's session best. But requirement "best score of session" and demo isn't session play. We can keep bestScore updated only player scores, while demo doesn't update best. But session perhaps includes both. More meaningful player best. Initial best 0. Demo food maybe current displayed? Could show DEMO score? UI can display 0 during demo. Maybe current score should show demo score behind overlay? Could update display score during demo but when start reset. Best only player. This is okay. Yet screenshot at 3 sec may show score 10 maybe. Fine.
Status badge with text "DEMO MODE" initial; after start "PLAYER 1"; pause "PAUSED"; over "GAME OVER". Dot color.
Controls: arrow/WASD, P pause, Space/Enter restart. Show keycaps.
Start overlay initial should be translucent enough see game. Panel maybe backdrop-filter blur(6px), but center hides snake area only. AI continues.
Need no hidden behind later. Canvas visible. Ensure start message explicit exactly "Click or press a key to start". Maybe overlay button includes. Good.
Canvas accessibility and focus. Click anywhere canvas focus. Need prevent scroll for arrows/space. keydown attached window. When overlay active, any key calls startPlayer. But pressing 'p' starts then doesn't pause. okay. If initial keydown ArrowUp, start player and also queue up to honor. If any non-control key maybe start but no move. Requirement press key. On initial, start and if recognized directional add to queue. Need avoid duplicate call from button click and key? mode state handles.
Mouse/touch: click/tap canvas starts. Could include swipe controls for mobile perhaps polished. Requirement keyboard, but responsive. Add pointer swipe on canvas: pointerdown/up track, start if demo, translate direction. Nice. No need. Could make board on mobile usable via swipe. Display footer "Swipe on mobile". No horizontal.
Game aesthetics and CSS:
:root color-scheme dark; --bg etc.
body margin0 min-height:100svh display:grid place-items:center; overflow-x:hidden; background:
radial-gradient(circle at 18% 10%, rgba(45,212,191,.13), transparent 32%),
radial-gradient(circle at 85% 80%, rgba(99,102,241,.1)...),
#050b14;
font-family Inter etc.
body::before maybe grid fixed.
.app width:min(100%, 780px); padding: clamp(12px,2vw,24px); display flex column align center.
topbar width var(--board-size) maybe. Need board size variable computed. .topbar, .game-frame, footer width: var(--board-size). --board-size: min(640px, calc(100vw - 32px), calc(100vh - 185px)); min() mixed should work. But viewport height 800 => 615. width viewport 1280 => 640. result 615. App padding means width available 1232, no issue. At 375 => min 640,343,615=343. App padding 12 => actual content width351, board343. Good. At short height e.g. 600 => 415. Good.
Need minimum board maybe 300? calc vh could less but adapt. no requirement. Use --board-size.
Header height maybe 62. gap 12. Board 615 + header58 + controls38 + footer? total 735 + padding. app fit 800. At width 1280 board615. Let's calculate: app padding 18 top bottom =36; topbar 58; gap12; board 615; statusbar34; gap? footer 28 = 783. okay. At 800 maybe slight. Let board calc(100vh - 205px)=595. Total 36+58+12+595+10+34+10+28=783. Fine. Use gap 10 maybe app. Structure topbar, game-shell containing board + status bar; footer. .app gap 12. board shell includes status row 34, gap8 => board+42. Total 36+58+12+595+42+12+25=780. Great.
At mobile 375x maybe screenshot height 800: board343, total ~500, centered vertically, ample. Good.
Board frame border 1px rgba, border-radius 20, padding? Canvas itself border radius. If frame padding 8 then board CSS width? Could use .board-wrap position relative; canvas width:100%; .board-wrap has overlay. Add outer border around canvas. If padding inside causes board size mismatch. .game-board width board, aspect ratio 1; position relative; border-radius. canvas width100%. Overlay absolute inset0. Fine. .game-shell width board. status row below.
Canvas background drawn with CSS too.
Topbar .brand logo custom: 42x42 square with mini snake CSS maybe use inline SVG? Allowed inline. Could put svg in HTML; external not. Requirement canvas for game, inline svg decorative okay. But output exactly HTML. Use inline SVG icon with gradient? gradients IDs. Maybe no need. CSS logo with letters and glowing dots. Could use an SVG snake icon. Polished. Inline svg:
<svg viewBox=0 0 48 48><rect...><path d=M... stroke...><circle...></svg>. Good.
Stat cards. label, strong, delta? Current score and best. Score value animated? On score update add class pop via CSS and JS remove. Use tabular nums.
Maybe BEST includes crown via unicode ◆; avoid emoji inconsistency. Use small SVG. Could use plain.
Status bar:
<div class=statusbar>
<div class=mode-pill><span class=status-dot></span><span id=statusText>Demo mode</span></div>
<div class=board-meta>24 × 24 GRID</div>
</div>
Footer control hints with kbd.
At mobile status etc.
Overlay:
<div class="overlay is-visible" id=overlay>
<div class=overlay-card>
<div class=kicker id=overlayKicker>AUTOPLAY DEMO</div>
<h1 id=overlayTitle>Watch the snake<br><span>take the wheel.</span></h1>
<p ...>
<button id=primaryButton>Start playing <span>↵</span></button>
<div class=overlay-hint><span class=pulse-ring></span> Click or press a key to start</div>
</div>
</div>
For game over:
kicker SESSION ENDED
title "Snake down!" etc.
button "Run it back" + Enter.
hint "Space or Enter to restart"
Score in panel maybe .result-score <span>FINAL SCORE</span><b id=finalScore>0</b>. Initial hide.
Pause:
"Paused" etc button Resume maybe P. P key.
Need overlay class variant. On initial perhaps overlay backdrop gradient and board visible around.
Button pointer event; overlay catches click. Add overlay.addEventListener pointerdown? if click outside button should start (requirement click). But button. game-board click handler if event target canvas starts. Overlay click perhaps if target === overlay starts. Button click starts. game-over clicking overlay? Should maybe restart on click. For initial and gameover. Paused click resume maybe.
Game logic details.
Let's draft JS in IIFE strict.
const canvas, ctx, scoreEl...
const GRID=24, DIRS object.
let snake=[], food=null, score=0,bestScore=0,direction={x:1,y:0}, queued=[], mode='demo', running? paused separate.
let previousPositions=[], animationStart=performance.now(), animationDuration=150;
let particles=[], floaters=[];
let lastStep=performance.now();
let boardPixelSize=0, cellSize=0, dpr=1;
let overlayState='start';
let demoResetAt etc.
Initialize:
snake = [{x:8,y:12},{7,12},{6,12},{5,12}] maybe head x8. AI safe.
food=spawnFood();
sync overlay start.
resizeCanvas() observe.
requestAnimationFrame(frame).
ResizeObserver:
function resizeCanvas() {
const rect=canvas.getBoundingClientRect();
const size=Math.max(1, Math.floor(rect.width));
dpr=Math.min(window.devicePixelRatio||1,2);
canvas.width=Math.round(size*dpr); canvas.height...
ctx.setTransform(dpr,0,0,dpr,0,0); boardPixelSize=size; cellSize=size/GRID;
}
Since setTransform and each frame no resetting. canvas CSS width 100%; height auto? Set aspect ratio 1/1, display:block. canvas width attr doesn't affect CSS if width/height 100%. .game-board canvas { width:100%; height:100%; } .game-board aspect-ratio.
If canvas border? game-board border 1 and box-sizing border-box; content dimensions smaller than board width because border, canvas 100% relative content. rect returns actual content width. Good.
Frame:
function frame(now) {
const dt=Math.min(.05,(now-lastFrame)/1000); lastFrame=now;
updateParticles(dt);
if ((mode==='demo'||mode==='player') && now-lastStep>=stepDuration) {
const steps=Math.min(3, Math.floor(...)); for each step
}
draw(now);
requestAnimationFrame(frame)
}
Set lastStep=now each step maybe subtract duration to preserve:
while (now-lastStep >= stepDuration && active) { lastStep += stepDuration; step(); }
No issue.
stepDuration mode dynamic. For demo 145 maybe. Player 118.
In step:
if mode demo direction = chooseAI.
else direction = consumeDirection().
old = snake.map.
next.
willEat.
collision:
const bodyToCheck = willEat ? snake : snake.slice(0,-1)
if out or bodyToCheck.some => gameOver.
Actually if moving into tail and food not there, tail moves so legal. Good.
newSnake = [next,...snake]; if willEat keep all else pop.
snake=newSnake.
previous=old.
animationStart = performance.now() perhaps use lastStep? Use now.
animationDuration=current step duration. If multiple catchup, lastStep; but animation reset each. Better animationStart=lastStep (the scheduled step time), animationDuration=stepDuration. Rendering interpolation could advance. If multiple steps, previous overwritten only latest. Fine.
Eat: score +=10; spawn; particles at cell center pixel. score pop; best if player. If demo, maybe score board? Could have demoScore separate. We can use score variable demo and reset player. best only player. Requirement current score in initial demo could animate. Fine.
After startPlayer reset score=0; best stays player best. Could if demo score > best? "best score session" arguably include demo. We can set bestScore = Math.max(bestScore, demoScore) on start maybe. But demo could score 30, then best 30 while player 0. That's legitimate session best though weird. Could label best session. We can update best during demo too. Then initial screenshot shows score. On start reset current but best persists. likely okay. "best score of the session" no distinction. Use update best on every eat. Then player can never beat demo? easy enough. Demo AI maybe score 10-20 by 3 sec. User then sees best 10. Fine.
Maybe avoid demo points counting by using separate demoScore but display score 0. Yet screenshot less lively but food visible. Best 0. I think game convention best should include actual player only. We can state "Best" session. Could define session starts when player starts. Use best update only mode player. Demo eats don't change score display? Requirement eating adds 10 points applies game generally, including demo? AI demo is game mode, so should. Better count. Then best update. Start reset score but best holds. Accept.
We can make demo run for 2 seconds before food? Food initial near path and AI eats within maybe. Need visible burst around 3 sec. Initial snake at 8,12 heading right, food at 13,12. AI should head to it and eat in ~4 steps *145=580ms. Particles visible fleeting by screenshot 3 sec maybe gone. Could spawn initial burst? But requirement particle burst when eating, occurs. Screenshot after 3 sec likely may not see particles. But game action visible. Could set food farther 8 cells; AI eats ~1 sec. Particle gone by 3 sec. The evaluator might interact? likely screenshot only. Could add ambient small particles? Not needed. Could have food glow.
Maybe AI intentionally explores and food gets eaten every ~1.5 sec, so at 3 seconds likely eating around 2.4 sec particles maybe still if life .8. Fine.
chooseAI implementation:
const directions = right,left,down,up; current dir. Exclude reverse.
is valid using current snake and tail.
bodyAfter = [next,...snake]; if next != food pop.
pathToFood BFS:
queue [next], visited; blocks Set bodyAfter excluding food? Food not body. BFS avoid body positions. Start next. Goal food. Need tail occupancy after move. bodyAfter includes next and body. Goal food free.
reachable count for area.
score = path ? 1000 - path.length : 0; plus area; minus manhattan*0.2. If path direct.
Potential loops.
Return best. If no valid return current (could collide) but AI robust maybe.
SpawnFood:
const occupied set; choose random from free. To avoid food directly adjacent? random.
Need maybe food not on snake.
Particles:
spawnBurst(gridPos, color '#ff6b57'):
center x=(pos.x+.5)*cell, y...
for i 0..16 angle random, speed 30-110 px/s relative maybe cell size; size 1.5-3.5; life .45-.8. update position in pixels. Since board resize, pixel particles okay and draw.
Also rings list? Could add shockwaves array {x,y,life,max}. Draw expanding circle.
Floating score "+10" at food, life 0.9, y decreases.
Use globalCompositeOperation lighter.
Draw:
ctx.clearRect.
const size=boardPixelSize.
background gradient createLinearGradient.
fillRect.
grid: ctx.strokeStyle rgba(122,255,216,.06), lineWidth1; each cell lines. Add alternating subtle cell fill maybe every 2? Could draw checkerboard with rgba white .012. 576 loops okay.
Border inner glow maybe.
Food draw:
cx=(food.x+.5)*cell; pulse=1+sin(now/220)*.08; radial gradient from #ffb36b to #ff4f5e. shadowColor #ff5f68; shadowBlur 18; circle radius cell*.24*pulse. Draw outer ring. Maybe tiny highlight.
Particles draw.
Snake:
compute interpolated positions. Need function getRenderPositions(now):
const t= mode active ? clamp((now-animationStart)/animationDuration,0,1) : 1. If start before first movement previous positions maybe same.
For each i:
from = previousPositions[i] || snake[i]
to=snake[i]
x=(from.x+(to.x-from.x)*t + .5)*cell.
But previous positions for growth: old length = new length-1. For last new segment index old missing, use from=to (appears at tail when growth). At t 0 new last appears immediately? For growth, old snake list length L, new length L+1. new segment L is old tail (since [newhead,...old] retains tail), from missing but could set from=old[last]? Wait new segment last = old tail, so appears stationary at t=0; correct set from=to maybe old tail. Fine.
Drawing body:
points = positions. We want tail to head. path ctx.beginPath move points[last], line through reversed points to points[0]. stroke width cell*.68, lineCap round,lineJoin round. Gradient along? Canvas gradient linear across board; createLinearGradient(0,size,size,0) colors #14b8a6 to #7CFFB2. shadow.
Maybe body center path with 1px lighter stroke? draw outer dark shadow and inner.
Draw body path width .64 cell.
Then segment shine circles maybe not, could look beads. Snake should coherent. Draw a thinner translucent stroke width .2 cell in light.
Need distinct scales: for i from tail to head, small dot at position maybe only every second, alpha. But coherent enough. We can draw segment circles with varying color at each point, but stroke already. Distinct head.
Potential path crossing itself on turns but normal.
Head:
const p=positions[0], dir direction. During animation if next turn, direction current. head radius cell*.38. radial gradient. shadow. Draw circle. Maybe draw a snout ellipse? Circle with eyes. Distinct head can be circle.
Eyes:
dx=dir.x,dy=dir.y; perp={x:-dy,y:dx}; for sign +/-:
ex=head.x+dx*cell*.12+perp.x*sign*cell*.16
ey=...
white circle radius cell*.095, shadow none.
pupil=head + dx*cell*.17 + perp*sign*cell*.16 maybe within. pupil radius .048. tiny glint.
Maybe head outline.
Direction movement uses direction set. Good.
At t=0 after movement direction corresponds new move, head at old position but eyes point new direction.
Draw paused dim? overlay covers.
Board decorative corner brackets maybe CSS.
Overlay updates:
function showOverlay(kind):
overlay.classList.add visible; set attributes.
initial: kicker "AI DEMO · LIVE", title with span, text "The serpent is on autopilot. Jump in when you're ready.", button "Start game", hint explicit. result hidden. Maybe "Click or press a key to start".
paused: kicker "TAKE A BREATHER", title "Paused", text "Your run is waiting...", button "Resume", hint "Press P to resume".
gameover: kicker "RUN COMPLETE", title maybe "Nice run!", text based score, result visible with final score, button "Play again", hint "Space or Enter to restart".
Need if user clicks board game over, start.
setOverlay maybe innerHTML? Avoid.
HTML spans.
Initial overlay centered card. The initial AI moves behind but overlay panel might cover much. Make card width 76%, background rgba(6,14,24,.78), backdrop-filter blur(10px), border. At 600 board, card maybe 390x 250. Board visible around. Good.
Add .overlay::before gradient maybe no.
Gameover overlay similar.
Buttons polished gradient, hover, active. Focus.
startPlayer:
if mode gameover? restart.
mode='player'; score=0; queued=[]; resetSnake maybe center? direction right; snake [{x:6,y:12},...]; food spawn; lastStep=now; prev positions same; status. Hide overlay. update score.
Could give starting snake length 4. Best remains.
Could preserve best from demo.
Need immediate movement. first step after duration.
restartGame similar mode player. Space/Enter when gameover restarts; if start initial any key.
P:
if mode player: mode='paused'; show overlay paused.
if mode paused: mode='player'; hide overlay; lastStep=performance.now() to avoid immediate catchup.
if mode demo perhaps maybe ignore? Initial P starts due any key requirement. Maybe P could start then pause? "Click or press a key to start" means yes. We can start and then if key p, perhaps pause? Yet user pressing P expects pause after game start. But no game yet. We can treat P in demo starts player and immediately pause? not necessary.
keydown:
const key=e.key.toLowerCase();
if ['arrow...',' ','enter','p']. includes preventDefault.
if mode==='start'? We don't have start mode; mode demo. If mode demo:
if key==='p' maybe startPlayer then toggle pause? Better startPlayer; if p then pause after. For other any key start.
if direction key: startPlayer(false perhaps reset) then queueDirection.
return.
if mode gameover:
if key===' '||key==='enter' restart; else maybe any key? Requirement space/enter restarts, so not any.
if mode paused:
if key==='p' resume; else maybe no.
if player:
direction keys queue.
p toggle pause.
space/enter? Could maybe restart? Requirement says game over Space/Enter restarts; during play maybe not. Ignore.
Need "Click or press a key to start" initial. Any key starts. But event from click button doesn't bubble key. fine.
Potential key repeat. Queue duplicates ignored.
startPlayer called by click. Canvas click event; overlay click target overlay. We can add:
primaryButton.addEventListener('click', e=> {e.stopPropagation(); if(mode==='paused') resume else startPlayer/restart});
overlay.addEventListener('click', e=> { if(e.target===overlay) ...});
gameBoard.addEventListener('pointerdown', e => { if mode demo/gameover start; if player capture swipe start coords; canvas.focus();});
But primary button event. gameBoard listener bubbles from button unless stop. yes stop.
For gameover clicking anywhere card? We can make card pointer events; only overlay background and button. Could let card click restart? no.
pointer swipe:
let pointerStart;
on pointerdown: if mode demo startPlayer and reset start maybe; if player capture.
on pointerup compute dx,dy threshold 18; queue direction based.
Maybe click canvas from paused resumes? Could resume.
Need prevent board click starting gameover? pointerdown. Good.
Queue:
function queueTurn(dir) {
const base= queued.length ? queued[queued.length-1] : direction;
if dir.x===-base.x && dir.y===-base.y return false;
if dir.x===base.x && dir.y===base.y return false;
// maybe if queue length >=3 shift? To honor both, max3.
queued.push(dir)
}
If queue full 3, shift oldest? Quick presses max. Use max 3; if full shift, but could lose first. Better max 4 enough. Grid. "Quick successive both honored". We use unlimited maybe up to 4. With 120ms interval, humans can press 3. Use 4. No reversal relative to last queued.
Consume:
if queued.length direction=shift.
Important AI direction when switching to player reset.
Self collision check body coords. If moving into neck etc.
Game over:
mode='gameover'; score current; updateBest; show overlay. particles maybe crash burst at head. Requirement particle burst eating only, but crash can too. Spawn red burst.
Status.
Best score current. `updateHUD`.
Score animation class.
Could `scoreEl.textContent=String(score).padStart(2,'0')` perhaps initial 00. best 00. At high use 3 digits no truncation.
Final score.
Best session variable no localStorage allowed. in-memory.
Pause behavior: `mode='paused'` and no steps. Overlay. Resume.
If player presses P key while queue exists, pause; resume retains queue? yes.
Start overlay hidden via class not display. opacity pointer-events none.
First paint: JS executes and AI moves. Overlay visible from HTML initially. Good.
Need ensure no errors if ResizeObserver unavailable? Chrome supports. Could use window resize fallback.
Canvas context round? standard.
Could use `roundRect` supported latest Chrome but avoid. Use arcs.
A polished board:
- CSS border and shadow.
- Canvas draws.
- .game-board::after pointer-events none inset border.
- subtle vignette overlay.
Maybe use a small "speed" or "length" indicator? User only score/best. Status bar can show grid and "SPEED 1.0x". But if dynamic. Add `Length 04` maybe. Yet status width. Could show "24×24" and length.
Stats header maybe score and best. title.
Footer controls.
Let's think screenshot aesthetics: 1280x800. body background, centered app. topbar brand and stat cards. Board with rounded corners. Overlay. Fine.
Potential requirement grid at least 20x20 cells centered page. Board is 24x24. Good.
Canvas can be inside .game-board. centered.
Smooth movement: interpolation. Good.
Distinct head eyes. Good.
Particle burst. Good.
AI demo: mode starts demo. Overlay says. Good.
"Click or press a key to start" overlay. We should ensure any key event starts, but iframe only receives after click; clear message. Good.
Input both quick presses honored. queue. Never reverse. Great.
No external. no storage. no forbidden window.open/alert etc.
Need exactly one fenced code block, no text. File complete.
Let's flesh code carefully.
HTML structure:
<!doctype html>
<html lang=en>
<head>...
<style>
...
</style>
</head>
<body>
<div class=ambient ...>
<div class=app>
<header class=topbar>
...
</header>
<main class=game-shell>
<section class=game-board id=gameBoard aria-label...>
<canvas id=game tabindex=0>...
<div class=board-vignette></div>
<div class=overlay id=overlay>
<div class=overlay-card>
...
<div class=result-score id=resultScore hidden>...
<button ...
<p class=overlay-hint...
</div>
</div>
</section>
<div class=statusbar>...
</main>
<footer...
</div>
<script>
...
</script>
</body>
</html>
`hidden` style ensure [hidden]{display:none!important}.
CSS:
* box sizing.
html,body {margin:0; min-height:100%; }
body { min-height:100svh; overflow-x:hidden; ...}
body:before fixed grid.
.app { width:min(100%, 800px); min-height:100svh; padding...; display:flex; flex-direction:column; align-items:center; justify-content:center; gap:clamp(10px,1.5vh,14px);}
topbar width var board; display flex etc.
--board-size: min(640px, calc(100vw - 28px), calc(100vh - 198px)); At mobile app padding 12 => viewport-28=347 but content width351, okay. At desktop 602 due height. Let's compute total accurately:
app min-height 800 with padding 18*2=36; justify center. content total:
topbar 58
gap 12
game-shell: board602 + statusbar32 + margin8 =642
gap12
footer24
total 748, plus36=784. fit.
`calc(100vh - 198px)` =602. yes.
At mobile board width347, height347; topbar maybe56, shell387, footer44 maybe wrap =56+12+387+12+44+24=535.
At very short 500: board302; okay.
If 375x667: width347.
Board border included aspect ratio. .game-board width var; aspect-ratio:1.
topbar width var.
footer width var.
At 1280 body board 602, centered. Great.
Media query max-width 520:
--board-size:min(100%, calc(100vw - 24px)); But CSS variable defined root; app padding 12 means 351, board calc351. topbar.
brand .subtitle hidden maybe.
logo 36.
stat min width 62; topbar gap8.
stat value 21.
footer .desktop-hint maybe.
overlay card padding 24.
overlay title 32.
At 375 overlay card width calc100%-32=315, title. good.
At max-height 620 maybe hide subtitle/footer etc.
Overlay card pointer-events auto. overlay pointer events auto. overlay background rgba(3,10,18,.18), maybe board visible. Initial panel itself 0.84. Need click background starts. Good.
button.
`#overlay.hidden` class `is-hidden`.
Board vignette z 2; overlay z3.
Canvas outline none.
Canvas drawing background:
function drawBoard:
ctx.save maybe.
const g=ctx.createLinearGradient...
fill.
checker:
for y x if ((x+y)&1) fill rgba... cell.
grid lines.
We need line positions crisp; ctx.beginPath all lines then stroke.
Add top subtle radial.
Food:
draw ring and orb.
`ctx.globalCompositeOperation='lighter'` then reset.
Food maybe render "berry" with tiny leaf? Distinct. Draw diamond maybe. Use circle with 4-point sparkle. Let's do orb:
radialGradient center.
shadow.
circle.
small highlight.
ctx.strokeStyle rgba; line.
pulse.
Snake:
Need body gradient orientation. createLinearGradient(cell*2,size-cell*2,size-cell*2,cell*2), stops.
ctx.shadowBlur=18, shadowColor rgba.
Path:
const pts=renderPoints(now)
ctx.beginPath(); for i=last downto0. But connecting tail to neck.
Stroke outer maybe:
ctx.strokeStyle='#0b6f62'; lineWidth=cell*.78;
then main gradient width .63; then highlight width .12 alpha. This yields smooth.
Then maybe draw scale dots: points for i from last to 1 every 1? circles radius .08 cell fill rgba(255...). But at turns can look. Draw only if i%2==0; each at point, alpha .18.
Tail maybe taper? Stroke same width. Could draw tail circle radius .28 cell at tail in darker. Body stroke already round.
Head gradient radial around head.
ctx.shadow.
head circle radius .39.
Outline circle stroke dark.
Maybe snout small shape? Circle enough. Eyes.
Could draw head direction arrow? no.
Interpolation:
let previousPositions = snake.map.
`animationStart=performance.now()`, `animationDuration=160`.
At reset previous=snake.
When move, previousPositions=old; snake=new. animationStart=lastStep? lastStep gets incremented before move. If now can be ahead by up to 16ms, t begins .1. Fine. Use `animationStart=performance.now()` to t0 at actual, causing a slight pause but negligible. Better animationStart = now - (now-lastStep)? Actually scheduled movement occurs now, so starts now. Use performance.now.
If multiple steps catch up, lastStep adjusted, but movement happened now. good.
Render points:
if active:
t=clamp((now-animationStart)/animationDuration)
else if gameover t=1 perhaps snake final.
from previousPositions[i] || snake[i]. For growth index new last: old? Let's set previousPositions during growth as old plus clone tail? We can derive:
const old=snake before;
new list.
previousPositions = old.map. For i new length-1 no prev -> to. At growth new last = old tail, to = old tail so stationary.
For segment i, to=snake[i], from=prev[i]. This works.
At t=1 body head at new. Gameover collision no move, previous maybe current? `endGame` leaves snake old, previous old. t=1 draws.
At initial t1.
However body path when interpolation each segment individually may produce head and segment1 separation around turns. Fine.
Step duration 125. Smooth movement if t.
`lastStep` performance.now at init.
Frame while:
const duration = mode==='demo'?155:118;
if now-lastStep>=duration:
lastStep=now; step()
This resets no catchup. At background tab resume one move only. okay. Smooth.
Maybe player speed gradually increases based score? Not required, but polished. Could cap 85ms. Keep constant for fairness.
AI demo reset if game over. `endGame` mode gameover would show overlay and replace start overlay—not desired during demo before user starts. In step if mode demo and collision: resetDemo(); return; do not end overlay. For player end.
Initial overlay persists.
`resetDemo` places snake at center-ish and food.
But if AI reaches gameover unlikely.
Demo status text updates.
Mode naming: 'demo','player','paused','gameover'. Initial overlay kind start.
`showStartOverlay` called initial.
Overlay text.
`hideOverlay`.
When start player, reset, mode player, hide. set status.
`startNewPlayer()`:
mode='player'; score=0; queued=[]; direction=right; snake = [{x:6,y:12},{5,12},{4,12},{3,12}] maybe head at 6. Food random but ensure not immediate collision. spawn.
prev.
animationStart=now; duration.
lastStep=now+? If immediate after 118ms.
status.
updateHud.
hide.
focus canvas.
Could initial demo best update, score reset but best remains. Fine.
Maybe best should reset when player starts? session includes demo. no.
When restart gameover same.
Game over final score and best:
updateBest score.
showGameOver:
kicker "RUN COMPLETE"
title innerHTML maybe "Snake <span>down.</span>" Avoid dynamic. text `You scored ${score} points...`.
result score.
Button "Play again".
Hint.
Use textContent for title and style spans separately? overlayTitle innerHTML controlled internal. Fine.
initial title.
Pause.
No security issue.
HUD update:
scoreValue.textContent = score.toString().padStart(2,'0');
bestValue...
lengthValue text.
score card class bump:
el.classList.remove('bump'); void el.offsetWidth; add.
Set CSS animation.
When demo score updates and best; good.
Maybe score text top says 010 etc. Nice.
Status:
statusText. dot class based data-mode perhaps statusPill.dataset.mode.
set status mode.
`statusText.textContent = 'AI DEMO'`, etc.
Length computed snake.length.
Gameboard click:
`gameBoard.addEventListener('pointerdown',(e)=> { if(e.target.closest('button')) return; pointerStart=...; if(mode==='demo'){ startNewPlayer(); } else if(mode==='gameover'){ startNewPlayer(); } else if(mode==='paused'){ resumeGame(); } canvas.focus({preventScroll:true}); });`
If start on pointerdown then pointerup. Fine. But click overlay background target overlay, pointerdown bubbles to gameBoard; mode demo -> start. Good. primary button pointerdown target button, skip; click handler starts.
For player pointer swipe. On pointerup:
if pointerStart and mode player, dx/dy. threshold max(16,cell*.7). queue.
Potential pointerdown on canvas focus; keyboard only after click. fine.
`canvas.addEventListener('contextmenu', prevent)`.
Key:
window.addEventListener('keydown', e=>{
const key=e.key;
const lower=key.toLowerCase();
const directional map.
if mode demo:
if directional: e.preventDefault(); startNewPlayer(); queueDirection(d); return;
if key===' '||key==='Enter'||lower==='p' or any: e.preventDefault if start? Requirement any key start. We should start for any nonmodifier. `if (!e.metaKey&&!e.ctrlKey&&!e.altKey){e.preventDefault(); startNewPlayer(); if(lower==='p') togglePause();}` This means pressing Tab starts game, not ideal accessibility but literal. Maybe only any visible key. Could start on any key excluding modifiers. It's okay. But if user tabs to button, keydown starts game unexpectedly. Developer says press a key. Maybe "Click or press a key to start" means input key. We can start on arrow/WASD/Space/Enter, not any. But phrase implies any key. To satisfy, any alphabetic/digit. Exclude Tab to preserve.
if key==='Tab' return.
start.
if directional queue after.
if lower==='p' maybe pause. If we start then pause, overlay "Paused", demo no longer visible. Pressing P likely should start and pause? We can just start, not pause.
return.
if gameover and space/enter restart.
if paused and p resume; space maybe resume? Usually Space resumes maybe. Requirement P pauses; not specify resume. Button. We can have Space resume.
if player: directional queue; p toggle.
});
Need if initial key is 'p', perhaps start; no pause. fine.
`e.repeat` directions: if held, queue may add same ignored. fine.
Queue max:
function enqueueDirection(next) {
const ref=queued.length?queued[last]:direction;
if opposite or same return;
if queued.length>=4) queued.shift(); // but could cause reversal relative after shift? Example current up, queue [right,down,left], add up? ref left -> up allowed. okay. But dropping oldest might allow reversal to current? The snake after consuming oldest right, then down... new up relative left not opposite current up? Queue sequence after dropping right = [down,left,up]; current up -> down opposite current, invalid even though ref left said allowed! This could cause reverse after dropped. To guarantee no reverse, should validate against last queued and max queue; if full ignore new, not shift. Use max4 and ignore when full. Or only queue max2; then quick two honored. Requirement quick successive likely two. Use max3 and ignore. If current up queue right then down (opposite right? no) accepted. Good. Never reverse because each new relative to last queued. The first relative current. Great.
We can allow 4. If full ignore. Sequence may include same directional? ignored. A 180 turn impossible because must take orthogonal intermediate, so okay.
At consume, direction = shift.
No reverse.
AI choose direction excludes opposite current. `direction` updated.
Collision with self: bodyToCheck. For growth include all. Food not on body.
When growth, food consumed and new food random.
Particles update:
particles.forEach p:
p.life-=dt; p.x+=vx*dt; y...
vy += 30*dt maybe.
filter.
shockwaves.
floaters y -= 28dt.
Need use CSS pixel coordinate after resize; if resize particles stale but fine.
Score burst at food center before grid changes; food position saved.
Canvas accessibility `aria-describedby`.
Grid lines at least visible.
Could also display speed? status bar right `24 × 24 • LEN 04`.
Let's have `metaText` updated "24 × 24 · LEN 04".
Footer:
<div class=controls>
<span><kbd>WASD</kbd><kbd>↑↓←→</kbd> move</span>
<span><kbd>P</kbd> pause</span>
<span><kbd>SPACE</kbd> restart</span>
<span class=mobile>Swipe to steer</span>
</div>
At desktop all.
At mobile footer could hide restart? Keep.
Potential board dimensions based viewport height causing on mobile browser `svh` not 100vh in CSS variable. Use `100svh` in calc? `calc(100svh - 198px)` supported. fallback 100vh before. Define --board-size: min(640px, calc(100vw - 28px), calc(100vh - 198px)); then @supports(height:1svh) override calc(100svh...). Not necessary.
At 1280x800, app width 800, board602.
At 375x800, board347. body app content width 351. Good.
At 375x667, board347, still fit total 535. good.
At 375x500, width347 and app total535 overflow vertical but body can scroll vertical, okay no horizontal. requirement page adapt gracefully. Could use height calc to shrink to302 at 500. `100vh - 198` =302, but width min chooses302, yes. So board302. Content total 490. okay.
Header board width may be 302 at short, fit brand and stats maybe overflows: brand 140 + stats140 + gaps=288 + padding okay. At 302 yes. For width375 no height issue chooses347. Fine.
At width280 maybe board252, header overflow. Not target 375 min. Could media query shrink brand title.
Let's choose board size variable:
--board-size: min(640px, calc(100vw - 28px), calc(100vh - 196px));
At 1280x800 604. Great.
`.app { width:100%; }` top width variable.
Could board at 800 be 604, total maybe. okay.
CSS overlay:
.overlay { position:absolute; inset:0; display:grid; place-items:center; padding:clamp(14px,...); background:rgba(3,9,17,.26); opacity:1; transition...; z-index:4; }
.overlay.is-hidden {opacity:0; pointer-events:none; visibility:hidden;}
.overlay-card { width:min(410px,90%); ... transform translateY(0) scale(1); }
initial title 38. At mobile 30.
Start overlay needs enough.
Kicker uppercase.
Icon maybe small serpent symbol in button? Could add `.demo-pulse` circles.
Maybe show an animated little robot/dot status.
Status bar height 32.
Footer.
Top brand:
logo inline svg with width42. SVG path uses stroke. `filter drop-shadow`.
Brand h1 margin0 font 20 letter spacing. subtitle 11.
Stats `.stat` width 92 height56 background rgba; border; border-radius14. score accent mint. Best accent violet? Best value yellow.
At mobile widths 72.
Current score label.
Need ensure current score 3-digit and doesn't resize. font 25.
Colors neon.
Snake gradient main bright #54f6b5 to #18c8b1. Food coral #ff6174.
Overlay title span accent mint.
Canvas background should fit.
AI initial food and snake. `resetDemo`:
snake [{x:8,y:12},{7,12},{6,12},{5,12}]; direction right; score maybe 0? If reset after collision. food spawn.
AI chooses path. BFS might choose right toward food.
Set previous.
Maybe initial food random might far. Set food first at {x:12,y:12} if not body. Spawn.
Score 0. AI will eat after ~4 steps. Best 10.
Then AI continues.
AI algorithm details:
function chooseAIDirection() {
const foodPos=food;
const options = Object.values(DIRS).filter(not reverse);
let best=null,bestValue=-Infinity;
for dir:
next...
if out continue;
const grows=next food;
const collisionBody = grows ? snake : snake.slice(0,-1)
if collision -> continue
const bodyAfter=[next,...snake]; if(!grows) bodyAfter.pop();
const path=findPath(next,food,bodyAfter);
const area=countReachable(next,bodyAfter)
const dist=Math.abs...
let val=(path!==null?1000-path.length*18:0)+area*2-dist*.1;
if path null perhaps -? area.
choose.
return best||direction.
}
`findPath` BFS avoiding `bodyAfter` set. But bodyAfter includes next. We start next and visited; neighbors blocked if bodyAfter includes them. The food is not blocked. Tail included after move unless grows. This is correct but BFS cannot move through body. Use queue arrays.
Potential bodyAfter includes head next and all snake. visited.
Return distance.
`countReachable` BFS same.
The direct food path may be blocked; AI goes around.
If food adjacent to body path etc.
At demo mode if AI no valid, resetDemo.
Food spawn random. New food maybe.
Drawing path interpolation during turns. We can smooth corners with quadratic curves instead of line segments. Path points maybe create diagonal connections; lineJoin round. Fine. But to make body natural, use a continuous trail history? Let's explore whether current interpolation yields gaps/odd.
At move right from snake head H(8), neck(7), body(6):
new head(9), new neck(8), new body(7), tail(6).
At t=.5 points:
seg0 x8.5, seg1 7.5, seg2 6.5, tail6. So distances .? tail to body .5; uniform. Great.
At turn up from head(9,12), neck(8,12):
new head(9,11), new neck(9,12), new body(8,12), tail(7,12).
t=.5:
head(9,11.5)
seg1 from(8,12) to(9,12)=8.5
seg2 from(7,12) to(8,12)=7.5
tail7
Distances head-seg1 sqrt(.5²+.5²)=.707 cell, seg1-seg2=1, giving kink/gap but line width .64, distance1 cell so just touch (radius .32 each). diagonal head-seg1 .707 overlap. good. At t=0: head9,12, seg1 8,12, etc. At t1 head9,11, neck9,12 vertical, good. Smooth.
At turn the neck path diagonal from old neck to old head. okay.
Head distinct. Good.
Particle burst drawn after food before snake maybe. Food. Draw particles after snake so visible.
Shockwave at food.
Canvas scaled. ctx transforms. `ctx.setTransform(dpr,0,0,dpr,0,0)` on resize. But `canvas.width` resets transform; then set. Each draw operations in CSS px. clearRect(0,0,size,size) works after transform? clearRect is transformed, yes. However actual canvas dimensions size*dpr. With transform dpr, clearRect CSS size covers. Good.
Set canvas.height. `ctx.imageSmoothingEnabled=true`.
Need avoid canvas content overflow border radius clipping. CSS border-radius and overflow hidden on board, canvas.
HTML inline style no external.
Let's write JS robustly.
Variables:
const $=...
const canvas = ...
const ctx = canvas.getContext('2d', {alpha:false});
const ui...
let boardSize=0, cell=0, dpr=1;
let snake=[], food=null, direction={...}, queue=[];
let score=0,bestScore=0;
let mode='demo';
let previousPositions=[];
let animationStarted=0, animationDuration=155;
let lastStep=performance.now(), lastFrame=lastStep;
let particles=[], waves=[], floaters=[];
let pointerStart=null;
`resizeCanvas`:
const rect=canvas.getBoundingClientRect();
const size=Math.max(100, Math.floor(rect.width)); But if hidden? board visible. min 100 can exceed CSS at tiny; no. use max1.
const nextDpr=Math.min(2, devicePixelRatio||1)
canvas.width=Math.round(size*nextDpr)
canvas.height...
ctx.setTransform(nextDpr,...)
boardSize=size; cell=size/GRID.
But canvas CSS width/height 100% and board border. rect width might be fractional. fine.
`ResizeObserver(resizeCanvas).observe(canvas);` Resize observer callback after initial. call requestAnimationFrame(resizeCanvas). Avoid observer loop? canvas internal width changes can alter CSS? CSS width 100%, height100%, no. okay.
window.addEventListener('resize',resizeCanvas) duplicates but harmless. Use observer only plus fallback.
Could use `new ResizeObserver(resizeCanvas).observe(gameBoard)`.
`resetSnake(demo)`:
if demo:
snake=[{8,12},{7,12},{6,12},{5,12}]; food={12,12}; direction right; score=0? If resetting demo after gameover, score maybe. We don't show overlay so set0. But best update maybe score before reset. Fine.
player:
snake [{6,12},{5,12},{4,12},{3,12}], direction right, food spawn.
At start player reset score 0.
prev copy; anim...
`spawnFood` uses snake.
If snake covers all return null. 24x24 plenty.
Random selection.
`resetDemo` should preserve best. set score0; updateHud; status demo.
Initial score maybe 0.
When AI eats score +10 and best update. So best increments.
`restart` after gameover `startNewPlayer`; best persists.
What if initial AI eats and best 10 then user starts, current 0, best10. okay.
`showOverlay(kind)`:
overlay.classList.remove('is-hidden');
switch.
Initial HTML already set, but function.
Maybe `overlay.dataset.kind`.
`hideOverlay`.
For initial, button text includes `Start game` and keycap Enter.
Pause button.
Need use `overlayTitle.innerHTML` but no user data.
Final score hidden false.
Gameover score.
Status update.
`startNewPlayer`:
if mode === 'player' return maybe.
mode='player'; score=0; queued=[]; direction={...}; snake...
food=spawnFood();
prev...
lastStep=performance.now();
hideOverlay;
setStatus('player');
updateHUD;
canvas.focus.
Potential key start direction up: startNewPlayer sets direction right, then enqueue up. If current direction right, up accepted. Great.
But if key left, left is opposite current right and ignored, so first Left won't move. User expects left should turn but cannot reverse immediately; snake moving right, left would reverse, so ignore is correct per never reverse. Could reset snake heading based input? Usually first direction left impossible if right. Could set initial direction right. Fine. Could allow initial left by choosing initial direction differently? no.
Maybe start snake heading right but input left should be ignored as reverse. fine.
`enqueueDirection` after start.
`resumeGame` mode player, hide, lastStep=now, status.
`togglePause` only player/paused. If player -> mode paused, show pause. If paused -> resume. If demo ignore.
P in player.
`endGame`:
mode gameover; queued=[]; updateBest; spawn crash particles at head; status; show overlay.
`updateHUD`: score padStart(2), best padStart(2), length. score bump only if value changed. Add parameter maybe.
Use scoreEl.textContent. On every reset no bump.
`flashScore` if previous score differs:
scoreValue.classList.remove('pop'); void scoreValue.offsetWidth; scoreValue.classList.add('pop').
best perhaps.
`setStatus(kind)`:
statusPill.dataset.mode=kind; statusText:
demo "AI DEMO"; player "LIVE RUN"; paused "PAUSED"; gameover "GAME OVER".
status dot CSS via data.
`modeLabel` maybe.
meta length.
Food eating:
const eaten=food; const next etc.
if eat:
score +=10; updateBest; spawnFood; spawnBurst(eaten); spawnFloater; updateHUD; maybe board class flash? `gameBoard.classList.remove('food-flash');...`.
Need spawn after snake updated. spawnFood excludes new snake.
Potential if food null.
`spawnBurst(gridPos)` uses cell.
colors array.
Particle update after board resized.
`waves` circle.
`floaters`.
Draw order:
drawBackground
drawFood
drawSnake
drawEffects
maybe vignette CSS.
Need show ambient food spark.
Background:
ctx.fillStyle gradient. create each frame? Could cache but okay. At 60 fps creating gradients maybe okay but unnecessary. Use solid and gradients each frame; canvas 600, fine.
Checkerboard 576 fills per frame maybe okay. 60fps 34k operations, okay. Could draw static background to offscreen but no external. We can use CSS background and canvas transparent? ctx alpha false. Better draw grid each frame. 24x24 trivial.
Draw grid lines each frame.
`ctx.fillStyle='rgba(...)'; for cells if parity fillRect`.
Gradient.
Food glow:
const pulse = 1+Math.sin(now*.005)*.08. shadowBlur scales.
draw outer ring ctx.stroke.
Maybe a small rotating diamond:
ctx.save translate; rotate(now/1800); fillStyle rgba; begin path four points; fill.
But orb enough.
Snake body:
points computed. To ensure body doesn't render over food if body includes food impossible.
Gradient.
Outer stroke maybe `rgba(0,20,24,.85)` width .82 then main .66. Shadow.
Main gradient.
Highlight path width .1 `rgba(255,255,255,.25)`.
Scale dots:
for i=1; i<points.length-? every 2:
circle at point radius cell*.08 fill rgba(2,60,55,.35). Could make textured snake. At turns points along interpolated positions. Good.
Tail perhaps gradient ends.
Head:
`headRadius=cell*.39`.
radial gradient from head x-.12cell to +.
fill.
stroke.
Maybe mouth line oriented at front:
ctx.strokeStyle dark; lineWidth cell*.035; move head+dir*.28 etc. But eyes enough.
Eyes positions. Need if direction diagonal? only cardinal.
Eye white radius .09. pupil radius .045.
`perp` for vertical. For sign.
Highlight tiny white.
For head direction right, eyes at x+0.11 and y±0.15, pupils x+0.16. Looks.
If direction left, x-.
If up, y-.
At head circle radius .39, eye center distance sqrt(.12²+.16²)=.2, pupil .23, within.
Pupils.
Could add tiny nostril? no.
Effects:
waves: ring at x,y radius=(1-life/max)*cell*1.2 + cell*.2, alpha.
particles globalComposite lighter, draw circles/rects.
floaters text with font bold cell*.42; stroke dark then fill white; alpha.
When score pop text "+10" mint.
Need use ctx save/restore.
Canvas board visual cells maybe food and snake.
`draw` if board size zero return.
Animation for paused: mode paused, t maybe remains current. We might set animation interpolation to 1? If paused midmove, should freeze where it is, not snap. mode paused isn't active but code `const t = mode==='gameover'?1 : clamp(...)`; for paused use current t. Use if mode gameover perhaps last state; gameover occurs after attempted collision so prior snake unchanged and prev same, t irrelevant. For paused keep `(now-animationStarted)/duration`, but now advances, would continue visually despite paused! Need freeze. Store `pauseAnimationProgress` when pausing or compute at pause time. Set `frozenProgress = clamp((performance.now()-animationStarted)/animationDuration)`. In draw if paused use frozenProgress. resume sets animationStarted=now-frozenProgress*duration maybe to continue. Good.
For demo/player t current.
For gameover t=1.
`togglePause`: frozenProgress = ...
resume: animationStarted=performance.now()-frozenProgress*animationDuration.
Similarly initial t=1.
Frame stepping and interpolation:
animationDuration set based mode before move. `step()`:
const stepDuration= mode demo ? 150:115;
animationDuration=stepDuration;
after new, animationStarted=performance.now();
lastStep maybe now. Frame calls now but performance.now same.
At reset animationDuration=stepDuration; animationStarted=now; previous=snake, so no movement.
Particles continue while paused? Typically pause should freeze game including particles maybe. We can skip update particles if paused? Pause overlay. To truly pause, skip. In frame `if(mode!=='paused') updateEffects`. Gameover effects can animate. initial demo yes.
When gameover, effects animate.
Score etc.
Frame:
function frame(now) {
const dt...
if(mode!=='paused') updateEffects(dt)
if(mode demo/player) {
const duration=...
if(now-lastStep >= duration){ lastStep=now; performStep(); }
}
draw(now)
request...
}
If perform step triggers mode gameover.
`lastStep` reset at start.
AI could choose every step.
`performStep`:
if mode demo:
const aiDir=choose; if aiDir direction=aiDir.
else:
const nextDir = queue.shift(); if next direction=...
const old...
...
if collision:
if mode demo { resetDemo(); } else endGame();
return
...
Need if mode changed? no.
For player queue may be empty keep direction.
When eating, best update.
`score` during demo. updateHUD. If initial overlay result hidden.
Reset demo after AI collision shouldn't update best? score had updated prior eats; best already. set score0 means current resets behind overlay, okay. Could leave score? no.
`showStartOverlay` initial:
overlayTitle.innerHTML='Watch the snake.<br><span>Take the wheel.</span>';
text...
kicker "AI DEMO • LIVE"
button.innerHTML='Start game <span class=button-key>↵</span>';
hint exact.
result hidden.
setStatus('demo').
Pause overlay:
title "Paused"
text "The grid is frozen..."
button "Resume <span>P</span>"
hint "Press P or Space to resume"
result hidden.
Gameover:
title `Run <span>complete.</span>` maybe.
text based score: score>=50 "Sharp instincts..." else...
result visible.
button "Play again <span>↵</span>"
hint "Space or Enter to restart".
Need `overlayTitle` style spans. gameover title with span accent coral maybe CSS `[data-kind=gameover] .accent?`. We can set overlay.dataset.kind and CSS.
Initial title markup.
Use innerHTML controlled.
Button event:
if mode paused resume; else if mode demo/gameover startNewPlayer. If player overlay hidden so no.
Potential `mode` initial demo and button click event after pointerdown gameBoard handler skip due target.closest('button') yes. Button click starts.
Overlay background pointerdown gameBoard starts; pointerup may queue? pointerStart set only if mode player perhaps. We'll set pointerStart after checks to avoid.
Event code:
gameBoard.addEventListener('pointerdown', e=>{
if (e.target.closest('button')) return;
if (mode==='demo'||mode==='gameover'){ e.preventDefault(); startNewPlayer(); }
else if(mode==='paused'){ resumeGame(); }
else { pointerStart={x:e.clientX,...}; }
canvas.focus...
});
But click on overlay card (not button) mode demo: starts. good.
For player, pointerStart set even clicking canvas. On pointerup if no movement no action.
`pointercancel`.
Swipe direction:
dx=e.clientX-start.x; dy...
if abs max < 18 return.
if abs dx > abs dy enqueue right/left else down/up.
Call preventDefault? pointer event passive default false. CSS touch-action:none on canvas/game-board so.
`gameBoard { touch-action:none; }` Overlay button still.
Potential page cannot scroll on board mobile but okay.
Keyboard focus and space scrolling: prevent default.
`window.addEventListener('keydown', handleKey, {passive:false})`.
`keydown` initial Tab excluded. If button focused and user presses Enter, browser also click event could double start. handle starts, then click event on button later sees mode player and overlay hidden; `primaryButton` still receives click maybe event target hidden? Since overlay hidden via visibility but focus? start handler mode player, primaryButton handler `if mode==='paused'... else if mode==='demo'||gameover start;` no double. Good.
Primary button handler only actions relevant.
When gameover Enter key restarts and browser might activate focused button too? Focus remains canvas because start focuses canvas, so no. If focused button, hidden after click. Fine.
Space gameover restart. `e.repeat` maybe hold space restarts repeatedly? On keydown repeat mode gameover first restarts, subsequent repeat in player ignored. okay.
P repeat could toggle rapidly if held. Check `if(e.repeat) return` for p. Direction repeats ignored same. We'll implement p only if !repeat.
Key mapping:
const keyDirections = {
ArrowUp:{x:0,y:-1}, ArrowDown..., ArrowLeft..., ArrowRight...,
w:{x:0,y:-1},a:{x:-1,y:0},s:{x:0,y:1},d:{x:1,y:0}
};
Use `e.key.toLowerCase()` yields "arrowup". map lowercase.
In handle:
const lower = e.key.toLowerCase();
const dir=keyDirections[lower].
if (mode==='demo') {
if (dir){ prevent; startNewPlayer(); enqueue(dir); }
else if (lower!=='tab' && !modifiers) {prevent; startNewPlayer();}
return;
}
if gameover:
if space/enter restart.
if paused:
if p or space/enter resume.
if player:
if dir enqueue.
if p toggle.
Could "press any key to start" and directional first up. Great.
If user clicks overlay, start player. If then press key, controls.
Queue base after start is direction right. If up accepted. Then quick left: base queued up, left accepted. At tick1 direction up; tick2 consumes left. Great. If quick up,left before tick both.
What if key down after up quickly; base up, down opposite -> ignored, preventing reverse. correct.
AI direction uses body coordinates.
Game over overlay score. `finalScore` displays padded maybe raw.
Best session no storage.
CSS score pop:
@keyframes score-pop {0 transform scale(1); 35 scale(1.18);100...}
.stat-value.pop.
Maybe avoid `void el.offsetWidth` considered okay.
Status dot animations.
@keyframes pulse.
`prefers-reduced-motion` reduce transitions; game animation still needed but okay.
Potential initial overlay and AI score card behind. The overlay background only .18; card opaque. Great.
Need perhaps small "Click or press a key to start" message visible exactly. We include.
Let's refine CSS dimensions and aesthetics.
CSS:
:root { --bg:#050912; --panel:rgba...; --mint:#61f7b7; --cyan:#35d4d0; --coral:#ff6675; --violet:#a78bfa; --board-size:min(...);}
*...
body font-family: ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; color...
body::before {content:"";position:fixed;inset:0;background-image:linear-gradient(...);background-size:44px 44px;mask-image:radial-gradient(...); pointer-events:none;}
body::after glows.
.app position relative z-index1.
.topbar {width:var; display:flex; align-items:center; justify-content:space-between; gap:16}
.brand display flex.
.brand-icon width44...
.brand-copy h1 font-size clamp...
.logo svg.
.stats display flex gap10.
.stat { min-width:92; height58; ...}
.stat.current border green etc.
.stat-label.
.stat-value.
.stat.best value violet or amber.
Use best label includes small icon? plain.
.game-shell width var.
.game-board {width:100%; aspect-ratio:1; position:relative; border:1px solid...; border-radius:22px; overflow:hidden; box-shadow:0 35px 80px..., 0 0 0 1px rgba; background;}
canvas absolute? Better display block width100 height100. If canvas absolute inset0, get bounding rect exact content. `.game-board canvas { position:absolute; inset:0; width:100%;height:100%;}`. Border content area? absolutely positioned containing block padding box; inset0 from padding edge inside border, width 100% may overflow? CSS absolute width100% relative padding box, likely. Simpler .game-board padding:0; canvas width100 height100. absolute.
.board-vignette absolute inset0 z2 pointer.
.statusbar display flex justify; height32; padding 0 4.
.mode-pill.
.meta.
footer.
Overlay card:
background linear-gradient rgba.
box shadow.
border top maybe.
kicker.
h1.
p.
button.
result score.
hint.
`overlay-hint::before` green dot pulse.
Initial title line break. `overlayTitle` line-height1.02.
At 600 board card max 400.
Button key style.
Primary button gradient mint, dark text, box shadow.
Top stats card background.
At mobile:
@media(max-width:520px){
:root {--board-size:min(100%, calc(100vw - 24px));} Note `100%` in variable on element? root percentage invalid in min? `--board-size: min(640px, calc(100vw - 24px));` simpler.
.app {padding:12px}
.brand-icon 36; .brand h1 15; .subtitle display:none
.topbar gap8
.stats gap6
.stat min-width:66; width66;height50;padding...
.stat-value font20
.game-board radius18
.statusbar font10
.footer .control-label maybe...
.overlay-card padding22
.overlay-title font30
.overlay p font13
}
At width375 topbar: brand icon36+gap9+copy maybe98 =143. stats 2*66+6=138, total gap8=289, board351, fine.
At 320 board296, topbar289 fits barely plus app width296. okay.
@media(max-height:650px){
.brand-subtitle display none;
.app gap8;
footer maybe...
}
No horizontal.
Canvas focus outline maybe `outline:none`.
Potential app `justify-content:center` with content > viewport in landscape could overflow but body vertical scroll allowed. At 800 fits.
No external fonts.
Now JS more detailed.
Use constants:
const DIRECTIONS = {
up:{x:0,y:-1}, down...
};
Object.values creates new references but okay.
Grid equality.
`isOccupied(pos, list)` uses list.some.
`spawnFood()`:
const occupied=new Set(snake.map...)
const free=[];
for...
if free length return random.
Could random retry but list.
`food=...`.
AI BFS:
function pathLength(start,target,blocked) {
if same return0.
const blockedSet=new Set(blocked.map(key)); blockedSet.delete(key(start))? bodyAfter includes start, so delete start.
const queue=[{pos:start,d:0}], visited Set(start)
while:
for dirs:
next; if out; if next target return d+1; if blockedSet has skip; visited.
return null.
}
Blocked includes target? food isn't body. fine.
`reachableArea`: similar, exclude start blocked, count. It can traverse food. okay.
Simulation collision:
const eating = same(next,food)
const collisionBody = eating ? snake : snake.slice(0,-1)
if collision. BodyAfter=[next,...snake]; if !eating pop.
But if next is tail and food also at tail impossible.
Path blocked bodyAfter. This treats tail position blocked even though after move tail remains at old tail if growth, but if eating tail is moved to new food? Actually on eating, body does not pop, so old tail remains occupied. correct. On non-eating pop tail, bodyAfter doesn't include old tail. correct.
The head old gets shifted body and remains occupied except start? bodyAfter includes old head. correct.
AI options may include direction to old head? from current head, neighbors only, old head not neighbor unless weird.
Area count bodyAfter.
When AI chooses direction that results food, eating. Great.
Potential path target start? if food current impossible.
`performStep`:
let dir=...
const old=snake.map(p=>({...p}));
const next...
if collision:
if mode==='demo' { resetDemo(); } else { endGame(); }
return.
const eats...
snake=[next,...snake]; if !eats snake.pop();
direction=dir;
previousPositions=old;
animationStarted=performance.now();
animationDuration=...
if eats...
Need `direction` update before collision? end head direction remains prior. For collision maybe set direction=dir so head eyes point collision direction. do after computing next before collision. Set.
`lastStep` already.
Food spawn after.
Particles at eaten grid center.
`food` is old object; spawnFood updates variable.
Score update.
Best.
Potential score resets demo. okay.
`updateHUD(true)`.
`updateBest`:
if(score>bestScore){best=score; bestEl maybe pop}. `updateHUD` handles.
`updateHUD(animateScore=false, animateBest=false)`.
`resetDemo`:
score=0; snake; food fixed; direction right; queue; prev; anim; mode remains demo; updateHUD; setStatus. It should not clear overlay? initial overlay remains visible. If reset while demo, hide? no.
Maybe best update before reset. yes.
No call show overlay because already start.
`startNewPlayer`:
mode player; score0; ...
spawn food random. But if food at cell directly in snake no.
animation.
hide.
status.
update.
canvas.focus.
Potential click focus itself may scroll but prevent.
`focus({preventScroll:true})` supported.
`endGame`:
mode gameover; update best; spawnCrash at head; set status; show.
Final text:
const final=score;
const message = final >=100 ? "That was lightning-fast..." etc.
`overlayTitle.innerHTML='Run <span>complete.</span>';`
result hidden=false; finalScore textContent=final;
Maybe best summary in result: resultBest? HTML only final score. Could include `<small id=resultBest>Best ...</small>`.
Add result row with final score and best. Nice.
HTML:
<div class=result-grid id=resultGrid hidden>
<div><span>Final score</span><strong id=finalScore>0</strong></div>
<div><span>Session best</span><strong id=finalBest>0</strong></div>
</div>
Game over overlay card larger but fits.
`finalBest=bestScore`.
Initial hidden.
Pause overlay no result.
Overlay button.
`showOverlay(kind)` switch, ensure class. `overlay.setAttribute('aria-hidden','false')`; hide true.
Overlay card role dialog aria-modal true? On game area. Set.
When hidden, `aria-hidden=true`.
`overlayTitle` etc.
`hideOverlay`: add is-hidden; aria hidden true.
Could CSS visibility transition delay. `.is-hidden {opacity:0; visibility:hidden; pointer-events:none;}` visibility immediately maybe transition no. Add transition `opacity .22s, visibility .22s`.
Need not.
Button click:
primaryButton.addEventListener('click', e => {
e.stopPropagation();
if(mode==='paused') resumeGame();
else if(mode==='demo'||mode==='gameover') startNewPlayer();
});
When overlay initial button click triggers gameBoard pointerdown skip due closest button. good.
Status mode data.
Score values.
Length status maybe `metaText.textContent = '24 × 24 · LEN ' + String(snake.length).padStart(2,'0');`.
At mobile may fit.
CSS meta letter spacing.
Effects:
function spawnFoodBurst(pos) {
const colors = ['#65ffba','#35e0cf','#fff2a8','#ff7a78'];
for i=0; i<18; i++...
angle = Math.random()*2π; speed=...
p.life=.45+...
waves...
floaters.push {x,y,text:'+10',life:.9,max:.9,color:'#a7ffd1'}
}
Particle `size` maybe cell*.04-.1.
When board resizes after spawn no matter.
Crash burst color coral 24 particles.
`updateEffects(dt)` loops; wave life; floaters.
p.vy += 25*dt.
Particle radius size maybe p.size.
floaters y -= 30dt.
`drawEffects`:
ctx.save; globalCompositeOperation='lighter';
waves stroke.
particles fill.
restore.
floaters separate with normal composite, alpha, font, textAlign center; stroke.
Maybe floaters drawn after particles.
`drawBackground`:
const gradient ctx.createLinearGradient(0,0,size,size); add #071a1c etc.
fill.
radial glow at maybe center.
checker.
Grid lines.
At each cell boundary. lineWidth=1/dpr? Since transformed, CSS 1 px. Use .65 maybe.
ctx.strokeStyle.
Maybe dotted intersections? no.
`ctx.fillStyle='rgba(94,255,204,.018)'` checker.
Board border inner: strokeRect(.5,.5,size-1,size-1) color.
`drawFood`: if null.
pulse.
outer glow with radial:
ctx.save; shadow.
radial gradient cx etc.
circle.
Then white small highlight.
Orb could be called apple. Distinct red.
Draw 4 spark points with `ctx.globalAlpha`.
Maybe ring around.
`drawSnake`:
if points empty.
Get t:
let t;
if(mode==='paused') t=frozenProgress;
else if(mode==='gameover') t=1;
else t=clamp((now-animationStarted)/animationDuration)
At reset animationStarted and prev same.
For each points.
Use `ctx.save`.
outer path:
function tracePath(ctx,points) { begin; move last; for i last-1..0 lineTo }
Stroke outer #052d2c width cell*.78; shadow maybe.
Main gradient.
Highlight maybe.
Could create gradient based size.
`ctx.shadowBlur=cell*.28` maybe 8 px. shadow color.
Outer shadow separately then main.
To avoid disconnected segments line width.
Scale dots. Points may overlap path; fine.
Tail highlight circle perhaps.
Then head.
`ctx.restore`.
Head gradient:
const g=ctx.createRadialGradient(p.x-cell*.12,p.y-cell*.14,cell*.04,p.x,p.y,cell*.48)
stops 0 #d6ffe9, .35 #7affc3,1 #20b99c.
Stroke #063f3d.
Shadow.
Circle.
Maybe draw small neck line.
Eyes:
for sign:
const ex = p.x + dir.x*cell*.12 + perp.x*sign*cell*.15;
...
sclera circle radius cell*.105 fill #f6fffb. pupil at ex+dir.x*.035 etc radius .052 fill #07151b.
Tiny glint at pupil - .02.
At 25px cell, eye radius2.6px, visible. At board600 cell25. good. Pupil1.3.
Head radius9.75.
Distinct.
Draw order particles after snake, so burst overlays.
Food maybe under snake but food not on snake.
`drawBoard` calls.
`draw` maybe overlay CSS dims.
Resize handling: boardPixelSize set to rect.width. If devicePixelRatio changes on zoom, window resize triggers.
Use `ctx.setTransform(dpr,...);` But `ctx.clearRect` after transform. Fine.
After resize, canvas internal resolution changes and resets context properties, set again each draw? We set properties each operation. transform remains until resize.
Set `ctx.setTransform(dpr,0,0,dpr,0,0)`.
Maybe rect.width includes board content width. canvas absolute width100%, okay.
Could use `ctx.roundRect` no.
No errors with `canvas.focus({preventScroll:true})` options supported Chrome.
`ResizeObserver` no issue.
Let's think user asked "single HTML file using vanilla JavaScript and a <canvas>." We meet.
Could there be expectation no HTML overlay? Requirement overlay showing score can be HTML overlay. yes.
Potential phrase "grid centered on page." board centered in app, app centered. yes.
Let's test mental runtime:
At script end call resetDemo(); resizeCanvas(); showOverlay('start'); requestAnimationFrame.
ResizeObserver callback perhaps after.
frame now; mode demo; lastStep initially performance.now; after 150ms perform step.
chooseAI:
snake head8,12, food12,12, direction right. options right next9 safe; path from9 to12 blocked bodyAfter positions 9,8,7,6. Path right 10,11,12 len3. area huge. right chosen.
At t move. Repeat until next12 food, collisionBody snake no food? eating true body includes snake; next12 not in. new snake [12,11,10,9,8]? Wait old snake [8,7,6,5], after right four times old positions. At food at12, after four moves snake head12 and body11,10,9,8 length5. eats, score10, spawn. path.
AI next seeks new food.
By 3 seconds ~20 steps, score perhaps several, best. Good.
AI may collide eventually but reset.
Queue behavior when player starts after demo: reset. Key arrow queued.
At first step if queue up, direction set up.
Collision self check.
One concern: score update in demo causes "best score" 10 etc, but when start resets score0, best remains. Fine.
Could best HUD animate in demo. yes.
Initial overlay card result hidden; CSS `[hidden]`.
ARIA.
Let's maybe include a small dynamic "AI demo" status and board label.
Visual polish maybe add score card top left. Good.
Could use inline SVG brand:
<svg viewBox="0 0 48 48" aria-hidden>
<defs><linearGradient id=...><stop...></linearGradient></defs>
<rect x=1 y=1 width=46 height=46 rx=14 fill... stroke...>
<path d="M11 29c0-6 5-8 10-8s8-2 8-6-3-6-7-6" fill=none stroke=url... stroke-width=6 stroke-linecap=round>
<circle cx=11 cy=29 r=3 fill="#d7ffef">
</svg>
Maybe path head at 11. eyes? enough.
No external.
CSS board corners.
Let's ensure no horizontal scrollbar due `body::before` etc. overflow-x:hidden. app width 100%. board calc. `stat` etc within.
At mobile footer controls could overflow if flex nowrap. Set flex-wrap:wrap; justify center. Good.
Let's formulate exact code.
Need include no text outside code fence. Final code lengthy. We can write.
Before final, let's inspect potential logic bug in `resetDemo`: initial overlay show? function not call overlay. We call show separately. If AI collision resetDemo while mode demo; `score=0`, best remains. okay.
`spawnFood` uses snake, but resetDemo sets food manually.
`startNewPlayer` calls spawnFood after snake set.
`updateHUD` status length.
`setStatus` receives 'gameover'. CSS status dot:
.mode-pill[data-mode="demo"] .status-dot {background:#fbbf24; box-shadow...}
player mint; paused violet; gameover coral.
Animation pulse maybe.
`showOverlay` gameover result finalBest update after updateBest. call updateBest then show.
`endGame`:
if(score>bestScore) bestScore=score;
spawnCrashBurst(snake[0]);
setStatus; updateHUD(false,true)
showOverlay('gameover')
No score bump maybe.
`updateBest` function separate.
`updateHUD(animateScore,animateBest)`:
const current=...
if animate score class.
best text. `if(animateBest)`.
If demo eats, score animate and best animate because best update. Pass both.
`updateBest` returns bool.
`performStep` eats:
const didImprove=score>best; score+=10; after increment compare. Better:
score +=10; const improved=score>bestScore; if improved bestScore=score.
spawn...
updateHUD(true,improved).
`resetDemo` score0 and update false; if best changed already no.
`showOverlay start` finalBest maybe irrelevant.
Board draw can use `ctx.fillRect` at x,y where cell fractional; okay.
Effects wave `life` decrement. wave max=.55, radius progress =1-life/max.
`ctx.globalAlpha = Math.max(0,life/max)`.
Float text position needs account cell. text baseline middle.
AI BFS performance each step: area BFS up to576, path BFS up to576, 4 candidates = 4608 operations every150ms fine.
`countReachable` blocked set includes bodyAfter; remove start. But bodyAfter includes current head next. We remove start, then BFS can potentially move back into old head? blocked yes. Fine.
Path to food treats food as traversable but if reached returns before blocked check; blocked doesn't contain food.
`pathLength` if next equals target. Fine.
Collision:
`const bodyToCheck = willEat ? snake : snake.slice(0,-1);`
If snake len1? no.
Check `same`.
Wall.
When non-eating, moving into current tail legal. The tail's old cell isn't in bodyToCheck. newSnake [next,...snake].pop removes tail. good.
When eating food, food not on snake by invariant.
Self body check includes head old but next cannot equal old head except weird wrap no.
New food never on snake. spawn list free.
Best session in-memory only. yes.
Smooth movement render: previousPositions on growth:
old length4, new length5, previous array old. For i4 from fallback snake[4] which is old tail at index3, to new snake[4]=old tail. okay.
But `from=previousPositions[i] || snake[i]`, snake[4] is old tail; good.
At t0, body points:
head old->new interpolation at t0 old head.
seg1 old neck -> new neck (old head) at t0 old neck.
seg2 etc.
seg4 old tail. all.
At t1 new.
Great.
At gameover collision, `direction` set attempted, `previousPositions` still from prior step? snake unchanged, animation maybe current prior t not finished; we set? In performStep set direction before collision but don't update animation. endGame sets mode gameover; draw t=1, snapping to current grid positions and direction attempted. fine.
`animationDuration` when reset set. In draw, t could >1 clamp.
Pause:
`togglePause` if player:
frozenProgress=clamp((performance.now()-animationStarted)/animationDuration)
mode paused; show.
if paused resume.
When paused, score card etc.
Space resume. Requirement P pauses, okay.
Click while paused on overlay button resume; background gameBoard pointerdown resumes. Great.
Initial overlay click background start. After clicking card, mode player and overlay hides while pointerdown event target card. Since event propagation same, gameBoard listener is on ancestor and executes after target; in handler checks mode at event time demo, starts. fine.
`pointerStart` maybe not set because branch mode demo. no.
Mobile swipe: pointerdown in player sets pointerStart. pointerup event might target elsewhere but listener on gameBoard if within; use window pointerup? Better add gameBoard pointerup and pointercancel. If finger moves outside still pointer capture needed. On pointerdown call `gameBoard.setPointerCapture(e.pointerId)` for player. Then pointerup delivered. Set capture.
Button pointerdown skip no capture.
On player pointerdown, `gameBoard.setPointerCapture`.
`touch-action:none`.
Compute.
Keyboard after iframe click: clicking canvas gives focus; listener window anyway. Good.
"Click or press a key to start" message remains initial. Since any key except Tab starts.
Potential auto AI behind overlay starts immediately. yes.
No high score gating.
Maybe initial score and best values show 00. Good.
Could add floating demo score. no.
Let's consider screenshot at 3 sec: AI may have eaten and score card 30. Overlay still. Board under panel maybe food/snake. Good.
Could status label "AI DEMO" shows action.
Button wording and hint.
Let's refine overlay title initial not too tall:
"Meet the serpent." maybe. User needs start. Use "Ready to slither?".
Title `Ready to <span>slither?</span>`.
Text "An AI serpent is warming up. Take control with the arrow keys or WASD."
Button Start game.
Hint exact.
Good.
Initial overlay title not dynamically change except function.
At game over title "Game <span>over.</span>"
result grid.
At pause "Paused".
CSS overlay title accent generic mint; gameover accent coral.
Use `data-kind`.
Potential CSS `backdrop-filter` okay in Chrome.
No external.
Let's calculate status bar board meta no overflow at 300. mode pill ~80, meta ~110. fine.
Footer mobile hide restart label? okay.
Let's now compose final code. Need be cautious exact fenced code block. No extra.
We can include comments for sections.
Let's write JS syntax no errors.
Detailed JS:
(() => {
'use strict';
const GRID=24...
const ctx=canvas.getContext('2d',{alpha:false});
if (!ctx) return; (canvas always)
const keyMap...
let...
const resize=...
...
function frame(now) { ... requestAnimationFrame(frame);}
resetDemo();
showOverlay('start');
resizeCanvas();
requestAnimationFrame(frame);
})();
Need `showOverlay` references overlay etc defined.
When `resetDemo()` calls `updateHUD` and `setStatus`, those functions declarations hoisted. okay.
`updateHUD` references scoreValue.
`resizeCanvas`: `const rect=canvas.getBoundingClientRect(); const size=Math.max(1, Math.round(rect.width));` If CSS canvas absolute width100% and board border, rect maybe604. dpr.
Set canvas width resets context. `ctx.setTransform`.
No `ctx.scale` accumulation.
`ResizeObserver` callback passes entries but function ignores.
Could `canvas.getBoundingClientRect().width` be 0 before layout? Script at end body, CSS loaded, board visible. yes.
If 0, size1 and later observer.
Grid drawing lines: `ctx.beginPath(); for i=0;i<=GRID;i++ { let p=Math.round(i*cell)+.5; ... }` Because CSS coordinates and dpr; +.5 maybe crisp. Fine.
Checker fill: cell fractional.
Gradient.
Canvas `ctx` alpha false means clear black but fill all.
Food radial gradient create each.
`drawFood` use shadow reset save.
Snake path gradient uses `ctx.createLinearGradient(0, size, size, 0)`.
Outer stroke shadow maybe:
ctx.shadowColor='rgba...'; shadowBlur=cell*.45; stroke outer.
Then shadowBlur=0 main.
If outer width .82 and main .64, dark border 0.09 cell each side. Nice.
Main gradient.
Highlight path `strokeStyle='rgba(255,255,255,.18)'`, width cell*.10, maybe line runs center giving glossy stripe.
Scales at points from tail to head every2. Draw circles radius .07 cell fill rgba(6,80,70,.3). But on highlight maybe.
Tail end maybe.
Head:
Draw neck? body already. circle.
Head eyes.
Direction variable always object.
`drawEffects` waves with `ctx.lineWidth=Math.max(1,cell*.05)`.
Particles p.size defined px maybe 2-4. Save.
Floaters draw:
ctx.globalAlpha=f.life/f.max; font `700 ${Math.max(13,cell*.45)}px ...`; lineWidth3; strokeText; fillText.
At mobile cell14, font13.
`spawnBurst` speed relative to cell: p.vx = cos*cell*(1.2+rand*2); p.size=cell*(.035+rand*.045). update.
At initial cell maybe 25.
Crash burst similarly.
`updateEffects` p.vy += cell*1.2*dt; p.vx *=? no.
Life.
AI:
`const options=[DIRECTIONS.right,...]`. DIRS object values. avoid reverse. `sameVector`.
`const rev={x:-direction.x...}`.
`isSafe`.
Path uses bodyAfter; bodyAfter includes next and snake. For area count remove next from blocked.
`findPath` function:
const blockedSet = new Set(blocked.map(posKey)); blockedSet.delete(posKey(start));
But if start equals target returns0 first.
Queue shift O(n) fine.
For each neighbor:
if out
if visited
if same target return d+1
if blocked skip
enqueue.
Note blocked check target before blocked; food not blocked.
Area count if next is food? food not blocked. count.
`bodyAfter` includes next; remove in helper.
Score. `pathScore`.
If path null but area huge.
Tie choose right maybe order.
`bestDir` fallback direction, but could reverse? no safe. AI may die. robust.
`resetDemo` on collision.
`spawnFood`: occupied snake; free. Random. Food may spawn under initial overlay no.
`status` length update.
Overlay:
HTML IDs. `overlayTitle` innerHTML.
`overlayText`.
`overlayKicker`.
`primaryButton.innerHTML = 'Start game <span class="button-key">↵</span>';`
Result.
`showOverlay`:
overlay.classList.remove; overlay.dataset.kind=kind; aria.
if start...
if pause...
if gameover...
For start `resultGrid.hidden=true`.
Button key span.
Could use `⏎` etc. Enter symbol `↵`.
Gameover final best.
Overlay card `aria-labelledby`.
`hideOverlay` sets aria.
`setStatus` uses statusPill dataset. `statusText`.
`metaText`.
`updateHUD`: scoreEl text padStart(2). Best pad. `scoreValue.classList` if animate. To retrigger animation, remove, `void scoreValue.offsetWidth`, add. This is okay.
CSS `.stat-value.pop {animation:...}`.
Best class maybe `pop-amber`, but same pop color current mint. Could add class and CSS value inherits; animation only transform. fine.
`startNewPlayer`:
const now=performance.now();
mode='player'; score=0; queue=[];
snake=[...]; direction={x:1,y:0}; food=spawnFood();
previousPositions=snake.map copy;
animationStarted=now; animationDuration=118; lastStep=now;
frozenProgress=1;
hideOverlay();
setStatus('player');
updateHUD();
canvas.focus...
Maybe `food=spawnFood` can put directly ahead; random.
`hideOverlay` class.
`lastFrame` unaffected.
`restart` same.
`resetDemo`:
const now...
mode remains demo (but if called initial).
score=0; queue=[];
snake...
direction right
food={x:12,y:12}; ensure.
prev.
animation...
lastStep now.
frozen=1.
set status, update.
Do not hide overlay. If mode somehow paused? only call mode demo.
Best persists.
When initial `showOverlay` after reset.
`togglePause`:
if mode player:
frozenProgress...
mode paused; showOverlay('pause'); setStatus paused.
else if paused resume.
`resumeGame`: mode player; hide; set status; lastStep now; animationStarted=now-frozen*duration; frozen=1.
Potential animation duration changed? no.
`handleKey`:
Avoid browser shortcuts ctrl etc.
const lower=String(e.key).toLowerCase();
const dir=keyMap[lower];
const isActivation = lower===' ' || lower==='enter';
if mode demo:
if dir || (lower !=='tab' && !modifier):
e.preventDefault()
startNewPlayer()
if(dir) enqueueDirection(dir)
return
But if lower 'p', starts and no pause. okay.
If lower 'escape'? starts. Fine.
If event meta/ctrl alt skip entirely.
if gameover:
if activation restart.
if paused:
if lower==='p'||activation resume.
if player:
if dir {prevent; enqueue}
if lower==='p' && !repeat {prevent;toggle}
Space during player not used, could prevent scroll? User may hit space and page no scroll because page centered; still prevent maybe to avoid button? We can prevent for space always when mode player? Not required. Better prevent if activation and gameover; during player space ignored but browser scroll. Body no scroll mostly. Could set e.preventDefault for space/enter in game states to avoid.
P.
If mode player and activation do nothing but prevent.
No restart during active; requirement says game over restart.
`Enter` while button overlay initial starts; key handler mode demo starts, then default prevented. good.
Any key initial including F5? keydown F5 browser reload; we might preventDefault and start, but e.key='F5', lower f5, we prevent default => disable reload! We shouldn't prevent non-action keys. Requirement press any key, but don't intercept function keys. We can start but not prevent default for non directional/space/enter? Starting game while F5 reload default harmless. `if (dir||activation) e.preventDefault(); start`. For letters start but allow. So F5 reloads anyway. Fine.
If user presses 'p', start.
Tab excluded and allowed.
Enqueue:
function enqueueDirection(next) {
const base=queue.length?queue[queue.length-1]:direction;
if same or opposite return false;
if queue.length>=4 return false;
queue.push({...});
return true;
}
No reversal. Note after player starts first key up, queue up. good.
If key right same ignored.
If key left opposite right ignored. Good.
`performStep`:
const now = performance.now();
let dir = direction;
if mode player && queue.length dir=queue.shift();
else demo dir=choose...
direction={...dir};
const next...
collision.
When collision demo, resetDemo. But direction maybe changed. reset.
`willEat=food && same`.
bodyToCheck.
if wall/collision.
if collision demo resetDemo; return; else end.
When not collision:
const old=...
snake=[next,...snake]; if !willEat snake.pop();
previousPositions=old;
animationStarted=now;
animationDuration=mode==='demo'?150:118;
if eat:
const eaten=food;
score +=10;
improve...
food=spawnFood();
spawn...
update...
else maybe no HUD.
The snake head new points. Food position.
`lastStep` set in frame before perform? frame:
if now-lastStep>=duration { lastStep=now; performStep(); }
duration based mode. Inside animation duration same.
At start mode.
If mode demo collision reset sets lastStep now.
If performStep enters gameover, frame stops next.
`chooseAIDirection` uses current direction. body.
`endGame` mode gameover; `updateBest`.
Crash particles uses head cell. `spawnCrashBurst`.
`showOverlay`.
`setStatus`.
`updateHUD`.
Need order updateBest before final best.
`spawnCrashBurst` uses position.
`updateEffects` runs gameover.
Resize particles coordinate relative old but okay.
Draw frozen progress:
`let t=1; if(mode==='paused') t=frozenProgress; else if(mode!=='gameover') t=clamp(...);`
During start mode demo reset animationStarted now prev same => t1.
During gameover t1.
When `mode==='gameover'` score HUD.
Canvas aria no updates.
One possible issue `bestScore` update demo and then reset current to0 when player starts. "best score session" top shows 10. Fine.
Maybe start button text says "Start game". Hint "Click or press a key to start". Good.
CSS body overflow-y auto but no horizontal.
Let's consider panel initial overlay doesn't include score, requirement only game over overlay score. yes.
Could initial board panel prevent seeing AI due `backdrop-filter` only card, overlay background transparent. Board around visible.
Let's add top board label "24 × 24" status.
Potential user expects keyboard controls after click; yes.
Now write final. Need ensure code fence exactly. Let's draft full code carefully.
CSS exact:
`:root { --board-size: min(640px, calc(100vw - 28px), calc(100vh - 196px)); }`
CSS min() with calc works.
At @media max520 override `--board-size: min(640px, calc(100vw - 24px));` This ignores height at mobile short, but typical 375x667; content 535 fits. At 375x500 content 535 overflow but vertical scroll okay. "adapt gracefully" fine. Could retain height calc as third: `min(640px, calc(100vw - 24px), calc(100vh - 180px))`; at 375x500 =>320, total maybe500. Use 180 to fit. But CSS at 800 =>620, width351 chooses351. Good. At height500=>320.
`--board-size: min(640px, calc(100vw - 24px), calc(100vh - 180px));`.
Desktop root already -196.
Mobile app padding12 means board351? 375-24=351, content351 exactly (375-24), app width375 padding12 each =>351. yes.
At desktop app padding20, board604 within.
Topbar width board.
App width100%; max-width800. At desktop board604 and app center. Topbar etc.
`justify-content:center` and min-height. On desktop content maybe:
topbar 58 + gap12 + shell (604+8+30=642)+gap12+footer28 =752; app padding32 ->784. fits.
At mobile topbar52+10+shell(351+8+28=387)+10+footer42 =501+24=525. fits.
Footer controls flex wrap can be one line at351? groups widths: move group (WASD 54 + arrows 80 + label24) ~165, pause60, restart90, swipe hidden =>315 plus gaps maybe >351, wraps to2 lines, footer42. okay.
At desktop all plus swipe maybe hidden desktop? `.mobile-only` display none desktop. good.
Overlay card at mobile maybe height:
kicker11+ margin, title60, p40, button44, hint16, padding44 + gaps ~250. board351 fits.
Gameover adds result grid70 ->320, fits barely in351 with overlay padding12; card width315 and content, total maybe330. Board351. Good. Need compact mobile result.
Result grid two columns, 64 height.
Overlay card max maybe.
At gameover card:
padding22*2=44; kicker12+10; title30+8; p34; result60+14; button44+14; hint14 =294. board351 fits with outer padding16 =>326. okay.
At desktop card board604, no issue.
`overlay-card {max-height:calc(100% - 24px); overflow:auto;}` but scrollbar perhaps. no.
Canvas `image-rendering` no.
Brand SVG.
HTML footer.
Let's ensure top `meta charset viewport`.
No console errors.
Potential issue in `showOverlay` setting `overlayTitle.innerHTML` strings with apostrophes no.
`primaryButton.innerHTML` with span.
`overlayText.textContent`.
`resultGrid.hidden = true/false`.
`overlay` `role=dialog` aria modal. hidden overlay still aria hidden.
`primaryButton` type button.
`canvas tabindex=0` outline none but focus visible maybe .game-board:focus-within border highlight. Since gameboard not focusable, canvas focus. CSS `.game-board:focus-within {border-color...}`.
`body` user-select maybe none? Text overlay; set user-select:none. fine.
`button` font inherit.
Score animation.
Maybe `filter` inline SVG.
Let's think exact AI path blocked:
Initial snake body. bodyAfter for right [9,8,7,6,5], remove start9, path 9->10->11->12. good.
Area BFS excludes body positions, lots.
After eating at12 body [12,11,10,9,8]. food new random. AI explores.
AI might choose a direction where next is old body? collision excludes tail only. body check. fine.
At every step area path. robust.
New food never snake: spawn excludes current snake after growth.
One potential self collision rendering: if moving into tail legal, snake after pop excludes. good.
Input queue validation relative last queued. Suppose current up, queue right, then left accepted (perp), then down accepted (perp to left), then right accepted (perp to down): 4. Sequence right,left,down,right. Movement current up -> right -> left -> down -> right, no immediate reversals. good.
If fifth ignore. never reverse.
Need ensure queued direction relative current if queue empties after each. direction updated consumed. yes.
Smooth interpolation after queue turn: direction set, body.
Game over if hits self. yes.
Space/Enter only gameover restart. P pauses. Pause overlay and P resume.
"Eating food adds 10 points and grows snake by one cell" yes. The body length increases; score.
"new food never spawns on snake" yes.
"best score of session" variable. no persistence.
Let's maybe set `bestScore` initial 0, update. session only.
All meaningful visible first paint. AI starts.
Could initial overlay prevent canvas keyboard? Window listener regardless, and message.
No network.
One concern developer duplicate instructions says exactly one fenced code block complete file. We'll comply.
Let's now formulate final code in full.