From aae8304ce473b44fcba7a82851eb1fb395729d43 Mon Sep 17 00:00:00 2001 From: Tim Evans Date: Tue, 3 Feb 2026 14:57:13 +0000 Subject: [PATCH] Initial commit: Snake game implementation Co-Authored-By: Warp --- AGENTS.md | 52 +++++++++++++++ index.html | 29 +++++++++ script.js | 182 +++++++++++++++++++++++++++++++++++++++++++++++++++++ styles.css | 108 +++++++++++++++++++++++++++++++ 4 files changed, 371 insertions(+) create mode 100644 AGENTS.md create mode 100644 index.html create mode 100644 script.js create mode 100644 styles.css diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..2a4e3ae --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,52 @@ +# AGENTS.md + +This file provides guidance to WARP (warp.dev) when working with code in this repository. + +## Project Overview + +This is a Snake game implemented with vanilla JavaScript, HTML5 Canvas, and CSS. The project has no build system or dependencies - it runs directly in the browser. + +## Running the Game + +Open `index.html` directly in a web browser: +```bash +open index.html # macOS +``` + +Or use a simple HTTP server for development: +```bash +python3 -m http.server 8000 +# Then visit http://localhost:8000 +``` + +## Architecture + +### Game State Management +The game uses an immutable state pattern managed in `script.js`: +- **State object**: Contains `snake` (array of segments), `dir` (current direction), `nextDir` (queued direction), `food` position, `score`, and `over` flag +- **Pure functions**: `step()` takes current state and returns new state without mutations +- **Game loop**: `setInterval` calls `step()` every 120ms, then `render()` updates canvas and DOM + +### Key Game Logic +- **Direction handling**: Prevents 180-degree turns via `isOpposite()` check +- **Collision detection**: `outOfBounds()` and `hitSelf()` determine game over conditions +- **Food placement**: `placeFood()` finds random empty cells by excluding snake positions +- **Growth mechanism**: When snake eats food, tail segment isn't removed in next step + +### Rendering Pipeline +1. `drawGrid()` - Clears and draws background grid +2. `drawFood()` - Renders food as a red circle +3. `drawSnake()` - Draws snake segments (darker green for head) +4. UI updates via direct DOM manipulation (`scoreEl`, `statusEl`) + +### Input Handling +- Arrow keys and WASD mapped in `directions` object +- `nextDir` queues direction changes to prevent mid-tick direction updates +- Enter key restarts game when over + +## Code Conventions + +- Uses IIFE to avoid global scope pollution +- Functional approach for game logic (pure `step()` function) +- Grid coordinates: 20x20 grid, each cell is 20px +- Color scheme defined in CSS custom properties in `styles.css` diff --git a/index.html b/index.html new file mode 100644 index 0000000..72627a2 --- /dev/null +++ b/index.html @@ -0,0 +1,29 @@ + + + + + + Snake + + + +
+
+

Snake

+
Score: 0
+
+ +
+ +
+
+ +
+ +
Use arrow keys or WASD to move.
+
+
+ + + + diff --git a/script.js b/script.js new file mode 100644 index 0000000..39833cd --- /dev/null +++ b/script.js @@ -0,0 +1,182 @@ +(() => { + const board = document.getElementById("board"); + const ctx = board.getContext("2d"); + const scoreEl = document.getElementById("score"); + const statusEl = document.getElementById("status"); + const restartBtn = document.getElementById("restart"); + + const gridSize = 20; + const cellSize = board.width / gridSize; + const tickMs = 120; + + const directions = { + ArrowUp: { x: 0, y: -1 }, + ArrowDown: { x: 0, y: 1 }, + ArrowLeft: { x: -1, y: 0 }, + ArrowRight: { x: 1, y: 0 }, + w: { x: 0, y: -1 }, + s: { x: 0, y: 1 }, + a: { x: -1, y: 0 }, + d: { x: 1, y: 0 }, + }; + + function isOpposite(a, b) { + return a.x + b.x === 0 && a.y + b.y === 0; + } + + function createInitialState() { + const start = { x: 9, y: 10 }; + const snake = [start, { x: 8, y: 10 }, { x: 7, y: 10 }]; + const dir = { x: 1, y: 0 }; + return { + snake, + dir, + nextDir: dir, + food: placeFood(snake), + score: 0, + over: false, + }; + } + + function placeFood(snake) { + const occupied = new Set(snake.map((seg) => `${seg.x},${seg.y}`)); + const empty = []; + for (let y = 0; y < gridSize; y += 1) { + for (let x = 0; x < gridSize; x += 1) { + const key = `${x},${y}`; + if (!occupied.has(key)) { + empty.push({ x, y }); + } + } + } + if (empty.length === 0) return null; + const pick = empty[Math.floor(Math.random() * empty.length)]; + return pick; + } + + function nextHead(head, dir) { + return { x: head.x + dir.x, y: head.y + dir.y }; + } + + function outOfBounds(pos) { + return pos.x < 0 || pos.x >= gridSize || pos.y < 0 || pos.y >= gridSize; + } + + function hitSelf(pos, snake) { + return snake.some((seg) => seg.x === pos.x && seg.y === pos.y); + } + + function step(state) { + if (state.over) return state; + + const dir = state.nextDir; + const head = state.snake[0]; + const next = nextHead(head, dir); + const ateFood = state.food && next.x === state.food.x && next.y === state.food.y; + const bodyToCheck = ateFood ? state.snake : state.snake.slice(0, -1); + + if (outOfBounds(next) || hitSelf(next, bodyToCheck)) { + return { ...state, over: true }; + } + const nextSnake = [next, ...state.snake]; + if (!ateFood) { + nextSnake.pop(); + } + + const nextFood = ateFood ? placeFood(nextSnake) : state.food; + + return { + ...state, + snake: nextSnake, + dir, + nextDir: dir, + food: nextFood, + score: state.score + (ateFood ? 1 : 0), + }; + } + + function drawGrid() { + ctx.clearRect(0, 0, board.width, board.height); + ctx.fillStyle = "#fdfdfb"; + ctx.fillRect(0, 0, board.width, board.height); + + ctx.strokeStyle = "#e5e2dc"; + ctx.lineWidth = 1; + + for (let i = 0; i <= gridSize; i += 1) { + const p = i * cellSize; + ctx.beginPath(); + ctx.moveTo(p, 0); + ctx.lineTo(p, board.height); + ctx.stroke(); + + ctx.beginPath(); + ctx.moveTo(0, p); + ctx.lineTo(board.width, p); + ctx.stroke(); + } + } + + function drawSnake(snake) { + snake.forEach((seg, index) => { + ctx.fillStyle = index === 0 ? "#2f5d50" : "#4a7a6f"; + ctx.fillRect(seg.x * cellSize + 1, seg.y * cellSize + 1, cellSize - 2, cellSize - 2); + }); + } + + function drawFood(food) { + if (!food) return; + ctx.fillStyle = "#b93b3b"; + ctx.beginPath(); + const cx = food.x * cellSize + cellSize / 2; + const cy = food.y * cellSize + cellSize / 2; + ctx.arc(cx, cy, cellSize / 2.6, 0, Math.PI * 2); + ctx.fill(); + } + + function render(state) { + drawGrid(); + drawFood(state.food); + drawSnake(state.snake); + + scoreEl.textContent = String(state.score); + statusEl.textContent = state.over ? "Game over. Press Restart or Enter." : ""; + } + + let state = createInitialState(); + let intervalId = null; + + function startLoop() { + if (intervalId) clearInterval(intervalId); + intervalId = setInterval(() => { + state = step(state); + render(state); + }, tickMs); + } + + function restart() { + state = createInitialState(); + render(state); + startLoop(); + } + + function handleKey(event) { + const key = event.key; + if (key === "Enter" && state.over) { + restart(); + return; + } + if (!Object.prototype.hasOwnProperty.call(directions, key)) return; + + const candidate = directions[key]; + if (isOpposite(candidate, state.dir)) return; + + state = { ...state, nextDir: candidate }; + } + + document.addEventListener("keydown", handleKey); + restartBtn.addEventListener("click", restart); + + render(state); + startLoop(); +})(); diff --git a/styles.css b/styles.css new file mode 100644 index 0000000..1b89adc --- /dev/null +++ b/styles.css @@ -0,0 +1,108 @@ +:root { + color-scheme: light; + --bg: #f6f5f2; + --panel: #ffffff; + --ink: #1f1f1f; + --muted: #6b6b6b; + --border: #d7d5cf; + --accent: #2f5d50; + --food: #b93b3b; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + font-family: "Georgia", "Times New Roman", serif; + color: var(--ink); + background: radial-gradient(circle at 20% 20%, #ffffff 0, var(--bg) 55%); +} + +.app { + max-width: 520px; + margin: 32px auto; + padding: 24px; + background: var(--panel); + border: 1px solid var(--border); + border-radius: 12px; + box-shadow: 0 10px 24px rgba(31, 31, 31, 0.08); +} + +.header { + display: flex; + align-items: baseline; + justify-content: space-between; + margin-bottom: 16px; +} + +h1 { + margin: 0; + font-size: 28px; + letter-spacing: 0.5px; +} + +.score { + font-size: 16px; + color: var(--muted); +} + +.game { + display: grid; + place-items: center; + gap: 10px; +} + +#board { + width: 100%; + max-width: 420px; + border: 1px solid var(--border); + background: #fdfdfb; +} + +.status { + min-height: 20px; + color: var(--muted); + font-size: 14px; +} + +.controls { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-top: 16px; +} + +button { + padding: 8px 14px; + border-radius: 6px; + border: 1px solid var(--border); + background: var(--accent); + color: #ffffff; + font-size: 14px; + cursor: pointer; +} + +button:focus { + outline: 2px solid #000000; + outline-offset: 2px; +} + +.hint { + font-size: 13px; + color: var(--muted); + text-align: right; +} + +@media (max-width: 480px) { + .controls { + flex-direction: column; + align-items: flex-start; + } + + .hint { + text-align: left; + } +}