From 01e50916738f69fb908fde0bdd89080d3f60e16f Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 10 Oct 2025 14:44:37 +0000 Subject: [PATCH] feat: Add snake game with basic functionality Co-authored-by: shane.koster --- game/game.js | 189 ++++++++++++++++++++++++++++++++++++++++++++++++ game/index.html | 28 +++++++ game/style.css | 23 ++++++ 3 files changed, 240 insertions(+) create mode 100644 game/game.js create mode 100644 game/index.html create mode 100644 game/style.css diff --git a/game/game.js b/game/game.js new file mode 100644 index 000000000..24f175a78 --- /dev/null +++ b/game/game.js @@ -0,0 +1,189 @@ +(() => { + const canvas = document.getElementById('board'); + const ctx = canvas.getContext('2d'); + const scoreEl = document.getElementById('score'); + const bestEl = document.getElementById('best'); + const startBtn = document.getElementById('startBtn'); + const pauseBtn = document.getElementById('pauseBtn'); + const restartBtn = document.getElementById('restartBtn'); + + const GRID = 24; // 24x24 cells + const CELL = canvas.width / GRID; + const BASE_SPEED_MS = 120; + + const DIR = { + UP: { x: 0, y: -1 }, + DOWN: { x: 0, y: 1 }, + LEFT: { x: -1, y: 0 }, + RIGHT: { x: 1, y: 0 }, + }; + + let state = { + snake: [{ x: 8, y: 12 }], + dir: DIR.RIGHT, + pendingDir: DIR.RIGHT, + food: spawnFood([{ x: 8, y: 12 }]), + score: 0, + best: Number(localStorage.getItem('snake_best') || '0'), + running: false, + gameOver: false, + tickMs: BASE_SPEED_MS, + lastTickAt: 0, + }; + + bestEl.textContent = String(state.best); + + function spawnFood(occupied) { + while (true) { + const x = Math.floor(Math.random() * GRID); + const y = Math.floor(Math.random() * GRID); + if (!occupied.some(p => p.x === x && p.y === y)) return { x, y }; + } + } + + function reset() { + state.snake = [{ x: 8, y: 12 }]; + state.dir = DIR.RIGHT; + state.pendingDir = DIR.RIGHT; + state.food = spawnFood(state.snake); + state.score = 0; + state.running = false; + state.gameOver = false; + state.tickMs = BASE_SPEED_MS; + scoreEl.textContent = '0'; + draw(); + } + + function start() { + if (state.gameOver) reset(); + state.running = true; + } + + function pause() { state.running = false; } + + function restart() { reset(); start(); } + + startBtn.addEventListener('click', start); + pauseBtn.addEventListener('click', pause); + restartBtn.addEventListener('click', restart); + + window.addEventListener('keydown', (e) => { + const key = e.key.toLowerCase(); + if (key === 'p') { state.running = !state.running; return; } + if (key === ' ' || key === 'enter') { if (!state.running) start(); return; } + const map = { + arrowup: DIR.UP, w: DIR.UP, + arrowdown: DIR.DOWN, s: DIR.DOWN, + arrowleft: DIR.LEFT, a: DIR.LEFT, + arrowright: DIR.RIGHT, d: DIR.RIGHT, + }; + const next = map[key]; + if (!next) return; + // Prevent reversing directly + const isOpposite = (a, b) => a.x + b.x === 0 && a.y + b.y === 0; + if (!isOpposite(next, state.dir)) state.pendingDir = next; + }); + + function tick(now) { + if (!state.lastTickAt) state.lastTickAt = now; + const elapsed = now - state.lastTickAt; + if (state.running && elapsed >= state.tickMs) { + state.lastTickAt = now; + advance(); + } + draw(); + requestAnimationFrame(tick); + } + + function advance() { + state.dir = state.pendingDir; + const head = state.snake[0]; + const next = { x: head.x + state.dir.x, y: head.y + state.dir.y }; + + // Wrap around edges + next.x = (next.x + GRID) % GRID; + next.y = (next.y + GRID) % GRID; + + // Self collision + if (state.snake.some((p, i) => i !== 0 && p.x === next.x && p.y === next.y)) { + state.running = false; state.gameOver = true; return; + } + + // Move + state.snake.unshift(next); + + // Eat + if (next.x === state.food.x && next.y === state.food.y) { + state.score += 1; + scoreEl.textContent = String(state.score); + state.food = spawnFood(state.snake); + if (state.score % 5 === 0 && state.tickMs > 60) state.tickMs -= 5; + } else { + state.snake.pop(); + } + + // Best + if (state.score > state.best) { + state.best = state.score; + localStorage.setItem('snake_best', String(state.best)); + bestEl.textContent = String(state.best); + } + } + + function draw() { + ctx.clearRect(0, 0, canvas.width, canvas.height); + + // Grid backdrop + ctx.fillStyle = '#0b1a2b'; + ctx.fillRect(0, 0, canvas.width, canvas.height); + ctx.strokeStyle = 'rgba(255,255,255,0.04)'; + for (let i = 0; i <= GRID; i++) { + ctx.beginPath(); + ctx.moveTo(i * CELL, 0); ctx.lineTo(i * CELL, canvas.height); ctx.stroke(); + ctx.beginPath(); + ctx.moveTo(0, i * CELL); ctx.lineTo(canvas.width, i * CELL); ctx.stroke(); + } + + // Food + drawCell(state.food.x, state.food.y, '#ff5d73'); + + // Snake + state.snake.forEach((p, idx) => { + const tone = 180 + Math.min(60, idx * 2); + drawCell(p.x, p.y, `hsl(${tone} 90% 60%)`); + }); + + if (!state.running) { + ctx.fillStyle = 'rgba(5,11,18,0.6)'; + ctx.fillRect(0, 0, canvas.width, canvas.height); + ctx.fillStyle = '#cfe6ff'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.font = 'bold 28px Inter, system-ui'; + ctx.fillText(state.gameOver ? 'Game Over' : 'Paused', canvas.width/2, canvas.height/2 - 14); + ctx.font = '14px Inter, system-ui'; + ctx.fillText('Press Start or Space to play', canvas.width/2, canvas.height/2 + 14); + } + } + + function drawCell(x, y, color) { + const px = x * CELL; const py = y * CELL; + const r = 6; + ctx.fillStyle = color; + roundRect(ctx, px + 1, py + 1, CELL - 2, CELL - 2, r); + ctx.fill(); + } + + function roundRect(ctx, x, y, w, h, r) { + ctx.beginPath(); + ctx.moveTo(x + r, y); + ctx.arcTo(x + w, y, x + w, y + h, r); + ctx.arcTo(x + w, y + h, x, y + h, r); + ctx.arcTo(x, y + h, x, y, r); + ctx.arcTo(x, y, x + w, y, r); + ctx.closePath(); + } + + reset(); + requestAnimationFrame(tick); +})(); diff --git a/game/index.html b/game/index.html new file mode 100644 index 000000000..079a3c5a4 --- /dev/null +++ b/game/index.html @@ -0,0 +1,28 @@ + + + + + + Snake + + + + + + +
+

Snake

+ +
+
Score: 0 ยท Best: 0
+
+ + + +
+

Use arrow keys or WASD. P to pause.

+
+
+ + + diff --git a/game/style.css b/game/style.css new file mode 100644 index 000000000..03ca5dc7a --- /dev/null +++ b/game/style.css @@ -0,0 +1,23 @@ +* { box-sizing: border-box; } +:root { color-scheme: dark light; } +html, body { height: 100%; } +body { + margin: 0; + font-family: Inter, system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, Noto Sans, Helvetica Neue, Arial, "Apple Color Emoji", "Segoe UI Emoji"; + display: grid; place-items: center; + background: radial-gradient(1000px 600px at 20% 0%, #0e1f2f 0%, #0a1623 40%, #050b12 100%); + color: #e6edf3; +} +.container { width: min(92vw, 640px); padding: 24px 16px 40px; } +h1 { margin: 8px 0 16px; font-weight: 800; letter-spacing: 0.3px; } +canvas { width: 100%; height: auto; image-rendering: pixelated; background: #081320; border-radius: 12px; border: 1px solid #0b2136; box-shadow: 0 20px 60px rgba(0,0,0,0.45), inset 0 0 0 1px rgba(255,255,255,0.03); } +.hud { display: grid; gap: 12px; margin-top: 16px; } +.score { font-weight: 600; color: #9cc4ff; } +.controls { display: flex; gap: 8px; } +button { + appearance: none; border: 1px solid #21476f; background: linear-gradient(180deg, #15314d, #0f2337); + color: #cfe6ff; padding: 8px 12px; border-radius: 8px; font-weight: 600; cursor: pointer; +} +button:hover { filter: brightness(1.1); } +button:active { transform: translateY(1px); } +.hint { color: #7a8aa6; margin: 8px 0 0; }