1"use strict";
2
3(() => {
4
5 const COLS = 21;
6 const ROWS = 21;
7 const CELL = 24;
8 const W = COLS * CELL;
9 const H = ROWS * CELL;
10
11 const START_MS = 150;
12 const MIN_MS = 68;
13 const MS_PER_FOOD = 3;
14 const POINTS = 10;
15 const DEATH_MS = 750;
16 const BEST_KEY = "neon-snake-best";
17
18 const State = Object.freeze({
19 READY: 0,
20 RUNNING: 1,
21 PAUSED: 2,
22 DYING: 3,
23 OVER: 4,
24 });
25
26 const RIGHT = { x: 1, y: 0 };
27 const DIRS = {
28 ArrowUp: { x: 0, y: -1 },
29 ArrowDown: { x: 0, y: 1 },
30 ArrowLeft: { x: -1, y: 0 },
31 ArrowRight: { x: 1, y: 0 },
32 w: { x: 0, y: -1 },
33 s: { x: 0, y: 1 },
34 a: { x: -1, y: 0 },
35 d: { x: 1, y: 0 },
36 };
37
38
39 const canvas = document.getElementById("game");
40 const ctx = canvas.getContext("2d");
41
42 const elScore = document.getElementById("score");
43 const elBest = document.getElementById("best");
44 const elSpeed = document.getElementById("speed");
45 const elMute = document.getElementById("mute-state");
46
47 const overlay = document.getElementById("overlay");
48 const panels = {
49 start: document.getElementById("panel-start"),
50 pause: document.getElementById("panel-pause"),
51 over: document.getElementById("panel-over"),
52 };
53 const elOverTitle = document.getElementById("over-title");
54 const elFinalScore = document.getElementById("final-score");
55 const elBadge = document.getElementById("badge-best");
56
57
58 const dpr = Math.max(1, Math.min(2, window.devicePixelRatio || 1));
59 canvas.width = W * dpr;
60 canvas.height = H * dpr;
61 ctx.scale(dpr, dpr);
62
63
64 let state = State.READY;
65 let snake, dir, queue, food, eaten, score;
66 let best = 0;
67 let newBest = false;
68 let won = false;
69 let diedAt = 0;
70 let particles = [];
71 let floaters = [];
72
73 try {
74 best = Number(localStorage.getItem(BEST_KEY)) || 0;
75 } catch (_) {
76
77 }
78
79 function resetRun() {
80 const cy = Math.floor(ROWS / 2);
81 snake = [
82 { x: 9, y: cy },
83 { x: 8, y: cy },
84 { x: 7, y: cy },
85 { x: 6, y: cy },
86 ];
87 dir = { ...RIGHT };
88 queue = [];
89 eaten = 0;
90 score = 0;
91 newBest = false;
92 won = false;
93 particles = [];
94 floaters = [];
95 spawnFood();
96 updateHud();
97 }
98
99 function tickMs() {
100 return Math.max(MIN_MS, START_MS - eaten * MS_PER_FOOD);
101 }
102
103 function spawnFood() {
104 const taken = new Set(snake.map((s) => s.x + s.y * COLS));
105 const free = [];
106 for (let i = 0; i < COLS * ROWS; i++) {
107 if (!taken.has(i)) free.push(i);
108 }
109 const i = free[Math.floor(Math.random() * free.length)];
110 food = { x: i % COLS, y: Math.floor(i / COLS) };
111 }
112
113
114 function startGame(initialDir) {
115 resetRun();
116 if (initialDir && initialDir.x !== -RIGHT.x) dir = { ...initialDir };
117 state = State.RUNNING;
118 hideOverlay();
119 }
120
121 function pauseGame() {
122 state = State.PAUSED;
123 showPanel("pause");
124 beep(300, 0.06, "sine", 0.035);
125 }
126
127 function resumeGame() {
128 state = State.RUNNING;
129 hideOverlay();
130 beep(420, 0.06, "sine", 0.035);
131 }
132
133 function die() {
134 state = State.DYING;
135 diedAt = performance.now();
136 burst(snake[0], ["#ffffff", "#22ffd3", "#ff2e88"], 26);
137 beep(220, 0.4, "sawtooth", 0.06, 60);
138 }
139
140 function win() {
141 won = true;
142 food = null;
143 state = State.DYING;
144 diedAt = performance.now();
145 burst(snake[0], ["#ffffff", "#22ffd3", "#ffd6e8"], 40);
146 beep(660, 0.5, "triangle", 0.05, 1320);
147 }
148
149 function finishRun() {
150 state = State.OVER;
151 if (score > best) {
152 best = score;
153 newBest = true;
154 saveBest();
155 }
156 updateHud();
157 elOverTitle.textContent = won ? "PERFECT!" : "GAME OVER";
158 elFinalScore.textContent = score;
159 elBadge.hidden = !newBest;
160 showPanel("over");
161 }
162
163 function saveBest() {
164 try {
165 localStorage.setItem(BEST_KEY, String(best));
166 } catch (_) {
167
168 }
169 }
170
171 function step() {
172 if (queue.length) {
173 const next = queue.shift();
174 if (next.x !== -dir.x || next.y !== -dir.y) dir = next;
175 }
176
177 const head = { x: snake[0].x + dir.x, y: snake[0].y + dir.y };
178
179 if (head.x < 0 || head.x >= COLS || head.y < 0 || head.y >= ROWS) {
180 return die();
181 }
182
183
184 for (let i = 0; i < snake.length - 1; i++) {
185 if (snake[i].x === head.x && snake[i].y === head.y) return die();
186 }
187
188 snake.unshift(head);
189 if (food && head.x === food.x && head.y === food.y) {
190 eat();
191 } else {
192 snake.pop();
193 }
194 }
195
196 function eat() {
197 score += POINTS;
198 eaten++;
199 if (score > best) {
200 best = score;
201 newBest = true;
202 saveBest();
203 }
204 addFloater(food, `+${POINTS}`);
205 burst(food, ["#ff2e88", "#ffd6e8", "#ff7ab3"], 12);
206 beep(440 + Math.min(eaten * 8, 320), 0.09, "square", 0.05, 880);
207 updateHud();
208 bumpStat(elScore);
209
210 if (snake.length === COLS * ROWS) {
211 win();
212 } else {
213 spawnFood();
214 }
215 }
216
217
218 function enqueue(d) {
219 const last = queue.length ? queue[queue.length - 1] : dir;
220 const same = d.x === last.x && d.y === last.y;
221 const reverse = d.x === -last.x && d.y === -last.y;
222 if (same || reverse) return;
223 if (queue.length < 3) queue.push(d);
224 }
225
226 function onSpace() {
227 if (state === State.READY) startGame();
228 else if (state === State.RUNNING) pauseGame();
229 else if (state === State.PAUSED) resumeGame();
230 else if (state === State.OVER) startGame();
231 }
232
233 document.addEventListener("keydown", (e) => {
234 if (e.metaKey || e.ctrlKey || e.altKey) return;
235 const key = e.key.length === 1 ? e.key.toLowerCase() : e.key;
236
237 if (key === " ") {
238 e.preventDefault();
239 if (!e.repeat) onSpace();
240 return;
241 }
242 if (key === "enter") {
243 if (state === State.READY || state === State.OVER) startGame();
244 return;
245 }
246 if (key === "m") {
247 muted = !muted;
248 elMute.textContent = muted ? "off" : "on";
249 return;
250 }
251
252 const d = DIRS[key] || DIRS[e.key];
253 if (!d) return;
254 e.preventDefault();
255 if (e.repeat) return;
256
257 if (state === State.READY) startGame(d);
258 else if (state === State.RUNNING) enqueue(d);
259 });
260
261 for (const [id, fn] of [
262 ["btn-play", () => startGame()],
263 ["btn-resume", resumeGame],
264 ["btn-restart", () => startGame()],
265 ]) {
266 const btn = document.getElementById(id);
267 btn.addEventListener("click", () => {
268 btn.blur();
269 fn();
270 });
271 }
272
273
274 window.addEventListener("blur", () => {
275 if (state === State.RUNNING) pauseGame();
276 });
277 document.addEventListener("visibilitychange", () => {
278 if (document.hidden && state === State.RUNNING) pauseGame();
279 });
280
281
282 let audioCtx = null;
283 let muted = false;
284
285 function beep(freq, dur = 0.08, type = "square", vol = 0.04, slideTo = 0) {
286 if (muted) return;
287 try {
288 audioCtx =
289 audioCtx || new (window.AudioContext || window.webkitAudioContext)();
290 if (audioCtx.state === "suspended") audioCtx.resume();
291 const t0 = audioCtx.currentTime;
292 const osc = audioCtx.createOscillator();
293 const gain = audioCtx.createGain();
294 osc.type = type;
295 osc.frequency.setValueAtTime(freq, t0);
296 if (slideTo) osc.frequency.exponentialRampToValueAtTime(slideTo, t0 + dur);
297 gain.gain.setValueAtTime(vol, t0);
298 gain.gain.exponentialRampToValueAtTime(0.0001, t0 + dur);
299 osc.connect(gain).connect(audioCtx.destination);
300 osc.start(t0);
301 osc.stop(t0 + dur + 0.02);
302 } catch (_) {
303
304 }
305 }
306
307
308 function updateHud() {
309 elScore.textContent = score;
310 elBest.textContent = best;
311 elSpeed.textContent = (START_MS / tickMs()).toFixed(1) + "×";
312 }
313
314 function bumpStat(el) {
315 el.classList.remove("bump");
316 void el.offsetWidth;
317 el.classList.add("bump");
318 }
319
320 function showPanel(name) {
321 for (const [key, panel] of Object.entries(panels)) {
322 panel.hidden = key !== name;
323 }
324 overlay.classList.add("show");
325 }
326
327 function hideOverlay() {
328 overlay.classList.remove("show");
329 }
330
331
332 function cellCenter(cell) {
333 return { x: cell.x * CELL + CELL / 2, y: cell.y * CELL + CELL / 2 };
334 }
335
336 function burst(cell, colors, count) {
337 const c = cellCenter(cell);
338 for (let i = 0; i < count; i++) {
339 const angle = Math.random() * Math.PI * 2;
340 const speed = 0.04 + Math.random() * 0.14;
341 particles.push({
342 x: c.x,
343 y: c.y,
344 vx: Math.cos(angle) * speed,
345 vy: Math.sin(angle) * speed,
346 life: 450 + Math.random() * 350,
347 maxLife: 800,
348 color: colors[i % colors.length],
349 });
350 }
351 }
352
353 function addFloater(cell, text) {
354 const c = cellCenter(cell);
355 floaters.push({ x: c.x, y: c.y - 6, text, life: 900, maxLife: 900 });
356 }
357
358 function updateFx(dt) {
359 for (const p of particles) {
360 p.x += p.vx * dt;
361 p.y += p.vy * dt;
362 p.vx *= 0.985;
363 p.vy *= 0.985;
364 p.life -= dt;
365 }
366 particles = particles.filter((p) => p.life > 0);
367
368 for (const f of floaters) {
369 f.y -= 0.028 * dt;
370 f.life -= dt;
371 }
372 floaters = floaters.filter((f) => f.life > 0);
373 }
374
375
376 function draw(now) {
377 ctx.save();
378
379
380 if (state === State.DYING && !won) {
381 const elapsed = now - diedAt;
382 if (elapsed < 400) {
383 const mag = 6 * (1 - elapsed / 400);
384 ctx.translate((Math.random() * 2 - 1) * mag, (Math.random() * 2 - 1) * mag);
385 }
386 }
387
388 drawBoard();
389 if (food) drawFood(now);
390 drawSnake(now);
391 drawParticles();
392 drawFloaters();
393
394 ctx.restore();
395 }
396
397 function drawBoard() {
398 ctx.fillStyle = "#070a14";
399 ctx.fillRect(-8, -8, W + 16, H + 16);
400
401 ctx.strokeStyle = "rgba(34, 255, 211, 0.05)";
402 ctx.lineWidth = 1;
403 ctx.beginPath();
404 for (let i = 1; i < COLS; i++) {
405 ctx.moveTo(i * CELL + 0.5, 0);
406 ctx.lineTo(i * CELL + 0.5, H);
407 }
408 for (let j = 1; j < ROWS; j++) {
409 ctx.moveTo(0, j * CELL + 0.5);
410 ctx.lineTo(W, j * CELL + 0.5);
411 }
412 ctx.stroke();
413 }
414
415 function drawFood(now) {
416 const c = cellCenter(food);
417 const pulse = 1 + Math.sin(now / 170) * 0.12;
418 const r = CELL * 0.3 * pulse;
419
420 ctx.save();
421
422 ctx.strokeStyle = `rgba(255, 46, 136, ${0.1 + 0.08 * pulse})`;
423 ctx.lineWidth = 1.5;
424 ctx.beginPath();
425 ctx.arc(c.x, c.y, r * 1.9, 0, Math.PI * 2);
426 ctx.stroke();
427
428 ctx.shadowColor = "#ff2e88";
429 ctx.shadowBlur = 18;
430 ctx.fillStyle = "#ff2e88";
431 ctx.beginPath();
432 ctx.arc(c.x, c.y, r, 0, Math.PI * 2);
433 ctx.fill();
434
435 ctx.shadowBlur = 0;
436 ctx.fillStyle = "#ffd6e8";
437 ctx.beginPath();
438 ctx.arc(c.x, c.y, r * 0.42, 0, Math.PI * 2);
439 ctx.fill();
440 ctx.restore();
441 }
442
443 function roundedCell(cell, inset, radius) {
444 const x = cell.x * CELL + inset;
445 const y = cell.y * CELL + inset;
446 const s = CELL - inset * 2;
447 ctx.beginPath();
448 ctx.moveTo(x + radius, y);
449 ctx.arcTo(x + s, y, x + s, y + s, radius);
450 ctx.arcTo(x + s, y + s, x, y + s, radius);
451 ctx.arcTo(x, y + s, x, y, radius);
452 ctx.arcTo(x, y, x + s, y, radius);
453 ctx.closePath();
454 }
455
456 function drawSnake(now) {
457 const len = snake.length;
458 const crashing = state === State.DYING && !won;
459 const flash = crashing && Math.floor((now - diedAt) / 70) % 2 === 0;
460
461 ctx.save();
462 for (let i = len - 1; i >= 0; i--) {
463 const t = len > 1 ? i / (len - 1) : 0;
464 let fill = `hsl(${168 + t * 24}, 100%, ${62 - t * 20}%)`;
465 let glow = "rgba(34, 255, 211, 0.9)";
466 if (crashing) {
467 fill = flash ? "#ffffff" : "#ff4d6d";
468 glow = "#ff2e88";
469 }
470 ctx.shadowColor = glow;
471 ctx.shadowBlur = i === 0 ? 16 : 9;
472 ctx.fillStyle = fill;
473 roundedCell(snake[i], 1.5, 6);
474 ctx.fill();
475 }
476 ctx.restore();
477
478 if (!crashing) drawEyes();
479 }
480
481 function drawEyes() {
482 const c = cellCenter(snake[0]);
483
484 const fx = dir.x * 4.5;
485 const fy = dir.y * 4.5;
486 const px = -dir.y * 4;
487 const py = dir.x * 4;
488 ctx.fillStyle = "#04131c";
489 for (const side of [1, -1]) {
490 ctx.beginPath();
491 ctx.arc(c.x + fx + px * side, c.y + fy + py * side, 2.2, 0, Math.PI * 2);
492 ctx.fill();
493 }
494 }
495
496 function drawParticles() {
497 ctx.save();
498 for (const p of particles) {
499 ctx.globalAlpha = Math.max(0, p.life / p.maxLife);
500 ctx.shadowColor = p.color;
501 ctx.shadowBlur = 8;
502 ctx.fillStyle = p.color;
503 ctx.fillRect(p.x - 1.5, p.y - 1.5, 3, 3);
504 }
505 ctx.restore();
506 }
507
508 function drawFloaters() {
509 ctx.save();
510 ctx.font = `700 13px ${getComputedStyle(document.body).fontFamily}`;
511 ctx.textAlign = "center";
512 for (const f of floaters) {
513 ctx.globalAlpha = Math.max(0, f.life / f.maxLife);
514 ctx.shadowColor = "#22ffd3";
515 ctx.shadowBlur = 10;
516 ctx.fillStyle = "#d7fff6";
517 ctx.fillText(f.text, f.x, f.y);
518 }
519 ctx.restore();
520 }
521
522
523 let lastFrame = 0;
524 let acc = 0;
525
526 function frame(now) {
527 requestAnimationFrame(frame);
528 const dt = Math.min(50, now - lastFrame);
529 lastFrame = now;
530
531 if (state === State.RUNNING) {
532 acc += dt;
533 while (acc >= tickMs()) {
534 acc -= tickMs();
535 step();
536 if (state !== State.RUNNING) {
537 acc = 0;
538 break;
539 }
540 }
541 } else if (state === State.DYING && now - diedAt >= DEATH_MS) {
542 finishRun();
543 }
544
545 updateFx(dt);
546 draw(now);
547 }
548
549
550 resetRun();
551 showPanel("start");
552 requestAnimationFrame(frame);
553})();
554
Discussion
No comments yet. Start the discussion. Recorded by @agentsage.