PatchHub

patchhub / snake Public

The classic snake game in your browser

snake / index.html 10543 bytes · raw
1<!DOCTYPE html>
2<html lang="en">
3<head>
4 <meta charset="UTF-8">
5 <meta name="viewport" content="width=device-width, initial-scale=1.0">
6 <title>Snake</title>
7 <style>
8 * { box-sizing: border-box; }
9 body {
10 margin: 0;
11 min-height: 100vh;
12 display: flex;
13 flex-direction: column;
14 align-items: center;
15 justify-content: center;
16 background: #1b1f2a;
17 color: #e8ecf1;
18 font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
19 }
20 h1 { margin: 0 0 12px; font-size: 2rem; letter-spacing: 2px; }
21 .scores {
22 display: flex;
23 justify-content: space-between;
24 width: 400px;
25 max-width: 90vw;
26 margin-bottom: 8px;
27 font-size: 1.1rem;
28 }
29 .scores strong { color: #7ee787; }
30 .board {
31 position: relative;
32 width: 400px;
33 max-width: 90vw;
34 aspect-ratio: 1 / 1;
35 }
36 canvas {
37 display: block;
38 width: 100%;
39 height: 100%;
40 background: #0f131b;
41 border: 3px solid #3a4152;
42 border-radius: 6px;
43 }
44 .overlay {
45 position: absolute;
46 inset: 0;
47 display: flex;
48 flex-direction: column;
49 align-items: center;
50 justify-content: center;
51 text-align: center;
52 background: rgba(10, 12, 18, 0.82);
53 border-radius: 6px;
54 padding: 20px;
55 }
56 .hidden { display: none !important; }
57 .overlay h2 { margin: 0 0 10px; font-size: 1.8rem; }
58 .overlay p { margin: 6px 0; color: #b8c0cc; }
59 .overlay button {
60 margin-top: 14px;
61 padding: 10px 26px;
62 font-size: 1.1rem;
63 font-weight: bold;
64 color: #0f131b;
65 background: #7ee787;
66 border: none;
67 border-radius: 6px;
68 cursor: pointer;
69 }
70 .overlay button:hover { background: #a5f3ad; }
71 .help { margin-top: 12px; color: #8b93a1; font-size: 0.9rem; }
72 </style>
73</head>
74<body>
75 <h1>SNAKE</h1>
76 <div class="scores">
77 <div>Score: <strong id="score">0</strong></div>
78 <div>Best: <strong id="best">0</strong></div>
79 </div>
80 <div class="board">
81 <canvas id="game" width="400" height="400"></canvas>
82
83 <div id="startScreen" class="overlay">
84 <h2>Snake</h2>
85 <p>Use the arrow keys to move.</p>
86 <p>Eat the red food to grow. Don't hit the walls or yourself!</p>
87 <button id="startBtn">Start</button>
88 <p class="help">or press any arrow key</p>
89 </div>
90
91 <div id="gameOverScreen" class="overlay hidden">
92 <h2>Game Over</h2>
93 <p>Your score: <strong id="finalScore">0</strong></p>
94 <p id="newBest" class="hidden"></p>
95 <button id="restartBtn">Play Again</button>
96 <p class="help">or press Space / Enter</p>
97 </div>
98
99 <div id="pauseScreen" class="overlay hidden">
100 <h2>Paused</h2>
101 <p class="help">Press P to continue</p>
102 </div>
103 </div>
104 <p class="help">Arrow keys: move &nbsp;•&nbsp; P: pause</p>
105
106 <script>
107 (function () {
108 const GRID = 20; // cells per side
109 const CELL = 400 / GRID; // pixel size of one cell
110 const START_SPEED = 130; // milliseconds per move
111 const MIN_SPEED = 60; // fastest the snake can go
112
113 const canvas = document.getElementById("game");
114 const ctx = canvas.getContext("2d");
115 const scoreEl = document.getElementById("score");
116 const bestEl = document.getElementById("best");
117 const finalScoreEl = document.getElementById("finalScore");
118 const newBestEl = document.getElementById("newBest");
119 const startScreen = document.getElementById("startScreen");
120 const gameOverScreen = document.getElementById("gameOverScreen");
121 const pauseScreen = document.getElementById("pauseScreen");
122
123 let snake, direction, inputQueue, food, score, speed, timer;
124 let state = "start"; // "start" | "playing" | "paused" | "over"
125
126 let best = 0;
127 try { best = parseInt(localStorage.getItem("snakeBest"), 10) || 0; } catch (e) {}
128 bestEl.textContent = best;
129
130 const DIRS = {
131 ArrowUp: { x: 0, y: -1 },
132 ArrowDown: { x: 0, y: 1 },
133 ArrowLeft: { x: -1, y: 0 },
134 ArrowRight: { x: 1, y: 0 },
135 };
136
137 function reset() {
138 const mid = Math.floor(GRID / 2);
139 snake = [
140 { x: mid, y: mid },
141 { x: mid - 1, y: mid },
142 { x: mid - 2, y: mid },
143 ];
144 direction = { x: 1, y: 0 };
145 inputQueue = [];
146 score = 0;
147 speed = START_SPEED;
148 scoreEl.textContent = score;
149 placeFood();
150 }
151
152 function placeFood() {
153 const free = [];
154 for (let x = 0; x < GRID; x++) {
155 for (let y = 0; y < GRID; y++) {
156 if (!snake.some(s => s.x === x && s.y === y)) free.push({ x, y });
157 }
158 }
159 food = free.length ? free[Math.floor(Math.random() * free.length)] : null;
160 }
161
162 function startGame(firstDir) {
163 reset();
164 if (firstDir && !(firstDir.x === -direction.x && firstDir.y === -direction.y)) {
165 direction = firstDir;
166 }
167 state = "playing";
168 if (document.activeElement) document.activeElement.blur(); // so Space can't re-click a button
169 startScreen.classList.add("hidden");
170 gameOverScreen.classList.add("hidden");
171 pauseScreen.classList.add("hidden");
172 draw();
173 scheduleTick();
174 }
175
176 function scheduleTick() {
177 clearTimeout(timer);
178 timer = setTimeout(tick, speed);
179 }
180
181 function tick() {
182 if (state !== "playing") return;
183
184 // Use the next queued turn, if any (lets quick key presses register one per move).
185 if (inputQueue.length) direction = inputQueue.shift();
186
187 const head = { x: snake[0].x + direction.x, y: snake[0].y + direction.y };
188 const eating = food && head.x === food.x && head.y === food.y;
189
190 // The tail moves away this turn unless we're eating, so it's safe to move into.
191 const body = eating ? snake : snake.slice(0, -1);
192 const hitWall = head.x < 0 || head.y < 0 || head.x >= GRID || head.y >= GRID;
193 const hitSelf = body.some(s => s.x === head.x && s.y === head.y);
194 if (hitWall || hitSelf) {
195 gameOver();
196 return;
197 }
198
199 snake.unshift(head);
200 if (eating) {
201 score++;
202 scoreEl.textContent = score;
203 speed = Math.max(MIN_SPEED, speed - 3);
204 placeFood();
205 if (!food) { draw(); gameOver(true); return; } // board full: you win!
206 } else {
207 snake.pop();
208 }
209
210 draw();
211 scheduleTick();
212 }
213
214 function gameOver(won) {
215 state = "over";
216 clearTimeout(timer);
217 draw(true);
218 finalScoreEl.textContent = score;
219 gameOverScreen.querySelector("h2").textContent = won ? "You Win!" : "Game Over";
220 if (score > best) {
221 best = score;
222 bestEl.textContent = best;
223 try { localStorage.setItem("snakeBest", String(best)); } catch (e) {}
224 newBestEl.textContent = "New best score!";
225 newBestEl.classList.remove("hidden");
226 } else {
227 newBestEl.classList.add("hidden");
228 }
229 gameOverScreen.classList.remove("hidden");
230 document.getElementById("restartBtn").focus();
231 }
232
233 function togglePause() {
234 if (state === "playing") {
235 state = "paused";
236 clearTimeout(timer);
237 pauseScreen.classList.remove("hidden");
238 } else if (state === "paused") {
239 state = "playing";
240 pauseScreen.classList.add("hidden");
241 scheduleTick();
242 }
243 }
244
245 function drawCell(x, y, color, inset) {
246 ctx.fillStyle = color;
247 const r = 4;
248 const px = x * CELL + inset, py = y * CELL + inset, size = CELL - inset * 2;
249 ctx.beginPath();
250 if (ctx.roundRect) ctx.roundRect(px, py, size, size, r);
251 else ctx.rect(px, py, size, size);
252 ctx.fill();
253 }
254
255 function draw(dead) {
256 ctx.clearRect(0, 0, canvas.width, canvas.height);
257
258 // subtle grid
259 ctx.fillStyle = "#141924";
260 for (let x = 0; x < GRID; x++) {
261 for (let y = 0; y < GRID; y++) {
262 if ((x + y) % 2 === 0) ctx.fillRect(x * CELL, y * CELL, CELL, CELL);
263 }
264 }
265
266 if (food) {
267 ctx.fillStyle = "#ff5c5c";
268 ctx.beginPath();
269 ctx.arc(food.x * CELL + CELL / 2, food.y * CELL + CELL / 2, CELL / 2 - 3, 0, Math.PI * 2);
270 ctx.fill();
271 }
272
273 snake.forEach((s, i) => {
274 const color = dead ? (i === 0 ? "#c9a227" : "#8a7a3a") : (i === 0 ? "#a5f3ad" : "#3fb950");
275 drawCell(s.x, s.y, color, 1);
276 });
277
278 // eyes on the head
279 const h = snake[0];
280 ctx.fillStyle = "#0f131b";
281 const cx = h.x * CELL + CELL / 2, cy = h.y * CELL + CELL / 2;
282 const off = CELL / 5;
283 const ex = direction.y !== 0 ? off : 0, ey = direction.x !== 0 ? off : 0;
284 const fx = direction.x * off, fy = direction.y * off;
285 [[1], [-1]].forEach(([sgn]) => {
286 ctx.beginPath();
287 ctx.arc(cx + fx + ex * sgn, cy + fy + ey * sgn, 2.2, 0, Math.PI * 2);
288 ctx.fill();
289 });
290 }
291
292 document.addEventListener("keydown", (e) => {
293 const dir = DIRS[e.key];
294 if (dir) e.preventDefault(); // keep arrow keys from scrolling the page
295
296 if (state === "start") {
297 if (dir) startGame(dir);
298 return;
299 }
300
301 if (state === "over") {
302 if (e.key === " " || e.key === "Enter") {
303 e.preventDefault();
304 startGame();
305 }
306 return;
307 }
308
309 if (e.key === "p" || e.key === "P" || e.key === "Escape") {
310 togglePause();
311 return;
312 }
313
314 if (state !== "playing" || !dir) return;
315
316 // Compare to the last queued direction so fast key combos work correctly.
317 const last = inputQueue.length ? inputQueue[inputQueue.length - 1] : direction;
318 const isReverse = dir.x === -last.x && dir.y === -last.y;
319 const isSame = dir.x === last.x && dir.y === last.y;
320 if (!isReverse && !isSame && inputQueue.length < 3) inputQueue.push(dir);
321 });
322
323 document.getElementById("startBtn").addEventListener("click", () => startGame());
324 document.getElementById("restartBtn").addEventListener("click", () => startGame());
325
326 // Draw the starting board behind the start screen.
327 reset();
328 draw();
329 })();
330 </script>
331</body>
332</html>