Initial commit: Snake game implementation

Co-Authored-By: Warp <agent@warp.dev>
This commit is contained in:
2026-02-03 14:57:13 +00:00
co-authored by Warp
commit aae8304ce4
4 changed files with 371 additions and 0 deletions
+52
View File
@@ -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`
+29
View File
@@ -0,0 +1,29 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Snake</title>
<link rel="stylesheet" href="styles.css" />
</head>
<body>
<main class="app">
<header class="header">
<h1>Snake</h1>
<div class="score">Score: <span id="score">0</span></div>
</header>
<section class="game">
<canvas id="board" width="400" height="400" aria-label="Snake game board"></canvas>
<div class="status" id="status" aria-live="polite"></div>
</section>
<section class="controls">
<button id="restart" type="button">Restart</button>
<div class="hint">Use arrow keys or WASD to move.</div>
</section>
</main>
<script src="script.js"></script>
</body>
</html>
+182
View File
@@ -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();
})();
+108
View File
@@ -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;
}
}