Initial commit: Snake game implementation
Co-Authored-By: Warp <agent@warp.dev>
This commit is contained in:
@@ -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
@@ -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>
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user