Pixel Hunter!
Browser Snake
Snake on canvas with crisp grid movement, an input queue that fixes THE classic Snake bug, wrap-around walls as an option, and touch controls so it plays on a phone.
Snake on canvas with crisp grid movement, an input queue that fixes THE classic Snake bug, wrap-around walls as an option, and touch controls so it plays on a phone.
<canvas><canvas>, the 2D ctxctx, the draw()draw() function and the requestAnimationFramerequestAnimationFrame loop. If you're starting here or need that boilerplate, grab it in one click: Episode 1 full template โ ยท JS Cheatsheet โSet up grid-based movement on a fixed tick inside rAF.
let snake = [{x:12,y:12},{x:11,y:12},{x:10,y:12}];
let dir = {x:1, y:0};
let lastTick = 0;
function loop(t) {
if (t - lastTick > 120) {
lastTick = t;
const head = { x: snake[0].x + dir.x,
y: snake[0].y + dir.y };
snake.unshift(head);
snake.pop();
}
draw();
requestAnimationFrame(loop);
}
requestAnimationFrame(loop);let snake = [{x:12,y:12},{x:11,y:12},{x:10,y:12}];
let dir = {x:1, y:0};
let lastTick = 0;
function loop(t) {
if (t - lastTick > 120) {
lastTick = t;
const head = { x: snake[0].x + dir.x,
y: snake[0].y + dir.y };
snake.unshift(head);
snake.pop();
}
draw();
requestAnimationFrame(loop);
}
requestAnimationFrame(loop);
Queue direction changes so fast double-presses cannot reverse the snake.
let inputQueue = [];
document.addEventListener("keydown", function(e) {
const map = { ArrowUp:{x:0,y:-1}, ArrowDown:{x:0,y:1},
ArrowLeft:{x:-1,y:0}, ArrowRight:{x:1,y:0} };
if (map[e.key]) inputQueue.push(map[e.key]);
});
// at each tick, before moving:
while (inputQueue.length) {
const want = inputQueue.shift();
if (want.x !== -dir.x || want.y !== -dir.y) {
dir = want;
break; // one turn per tick
}
}let inputQueue = [];
document.addEventListener("keydown", function(e) {
const map = { ArrowUp:{x:0,y:-1}, ArrowDown:{x:0,y:1},
ArrowLeft:{x:-1,y:0}, ArrowRight:{x:1,y:0} };
if (map[e.key]) inputQueue.push(map[e.key]);
});
// at each tick, before moving:
while (inputQueue.length) {
const want = inputQueue.shift();
if (want.x !== -dir.x || want.y !== -dir.y) {
dir = want;
break; // one turn per tick
}
}
const want = inputQueue.const want = inputQueue.();
if (want.x !== ();
if (want.x !== || want.y !== -dir.y) {
dir = want;
|| want.y !== -dir.y) {
dir = want;
;
};
}
Add food that respawns safely and grows the snake.
function spawnFood() {
while (true) {
const f = { x: Math.floor(Math.random() * 25),
y: Math.floor(Math.random() * 25) };
const onSnake = snake.some(c => c.x === f.x && c.y === f.y);
if (!onSnake) return f;
}
}
let food = spawnFood();
// in the tick:
snake.unshift(head);
if (head.x === food.x && head.y === food.y)
food = spawnFood(); // grew, no pop
else
snake.pop();function spawnFood() {
while (true) {
const f = { x: Math.floor(Math.random() * 25),
y: Math.floor(Math.random() * 25) };
const onSnake = snake.some(c => c.x === f.x && c.y === f.y);
if (!onSnake) return f;
}
}
let food = spawnFood();
// in the tick:
snake.unshift(head);
if (head.x === food.x && head.y === food.y)
food = spawnFood(); // grew, no pop
else
snake.pop();
Implement both wall modes and self-collision death.
const wrapMode = document.getElementById("wrap").checked;
if (wrapMode) {
head.x = (head.x + 25) % 25;
head.y = (head.y + 25) % 25;
} else if (head.x < 0 || head.x > 24 ||
head.y < 0 || head.y > 24) {
dead = true;
}
if (snake.some(c => c.x === head.x && c.y === head.y))
dead = true;const wrapMode = document.getElementById("wrap").checked;
if (wrapMode) {
head.x = (head.x + 25) % 25;
head.y = (head.y + 25) % 25;
} else if (head.x < 0 || head.x > 24 ||
head.y < 0 || head.y > 24) {
dead = true;
}
if (snake.some(c => c.x === head.x && c.y === head.y))
dead = true;
head.x = (head.x + head.x = (head.x + ) % ) % ;;
Add swipe input so the game works on any phone.
let touchStart = null;
canvas.addEventListener("touchstart", function(e) {
touchStart = { x: e.touches[0].clientX,
y: e.touches[0].clientY };
e.preventDefault();
});
canvas.addEventListener("touchend", function(e) {
if (!touchStart) return;
const dx = e.changedTouches[0].clientX - touchStart.x;
const dy = e.changedTouches[0].clientY - touchStart.y;
if (Math.abs(dx) > Math.abs(dy))
inputQueue.push({ x: dx > 0 ? 1 : -1, y: 0 });
else
inputQueue.push({ x: 0, y: dy > 0 ? 1 : -1 });
});let touchStart = null;
canvas.addEventListener("touchstart", function(e) {
touchStart = { x: e.touches[0].clientX,
y: e.touches[0].clientY };
e.preventDefault();
});
canvas.addEventListener("touchend", function(e) {
if (!touchStart) return;
const dx = e.changedTouches[0].clientX - touchStart.x;
const dy = e.changedTouches[0].clientY - touchStart.y;
if (Math.abs(dx) > Math.abs(dy))
inputQueue.push({ x: dx > 0 ? 1 : -1, y: 0 });
else
inputQueue.push({ x: 0, y: dy > 0 ? 1 : -1 });
});
Finish with a speed curve, a pretty snake and a persistent record.
const tickTime = Math.max(60, 120 - snake.length * 2);
// gradient body while drawing (i = segment index):
snake.forEach(function(c, i) {
const light = 55 - Math.min(35, i * 1.5);
ctx.fillStyle = "hsl(155, 70%, " + light + "%)";
ctx.fillRect(c.x * 20 + 1, c.y * 20 + 1, 18, 18);
});const tickTime = Math.max(60, 120 - snake.length * 2);
// gradient body while drawing (i = segment index):
snake.forEach(function(c, i) {
const light = 55 - Math.min(35, i * 1.5);
ctx.fillStyle = "hsl(155, 70%, " + light + "%)";
ctx.fillRect(c.x * 20 + 1, c.y * 20 + 1, 18, 18);
});
const tickTime = Math.const tickTime = Math.(60, 120 - snake.(60, 120 - snake. * 2); * 2);
Grid ticks, the input queue, food & growth, wall death vs wrap-around, touch swipes, the speed curve, gradient body and a saved best score , one file. Save it as snake.htmlsnake.html, open it, and play. Stuck on a step? Diff your file against this.
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Pixel Hunter - Snake</title>
<style>
body { display:flex; flex-direction:column; align-items:center; gap:12px;
background:#0d1117; color:#e8eaf2; font-family:system-ui, sans-serif; padding:16px; }
canvas { background:#0a0e14; border:2px solid #1e2733; border-radius:8px; touch-action:none; }
.row { display:flex; gap:16px; align-items:center; font-size:.95rem; }
label { display:flex; gap:6px; align-items:center; cursor:pointer; }
</style>
</head>
<body>
<h2>๐ Pixel Hunter</h2>
<div class="row">
<span>Score: <b id="score">0</b></span>
<span>Best: <b id="best">0</b></span>
<label><input type="checkbox" id="wrap"> Wrap-around walls</label>
</div>
<canvas id="game" width="500" height="500"></canvas>
<div class="row">Arrow keys / swipe to move ยท Space to restart</div>
<script>
const canvas = document.getElementById("game");
const ctx = canvas.getContext("2d");
const CELL = 20, GRID = 25;
let snake, dir, inputQueue, food, dead, lastTick, score, best;
best = Number(localStorage.getItem("snakeBest") || 0);
document.getElementById("best").textContent = best;
function reset() {
snake = [{x:12,y:12}, {x:11,y:12}, {x:10,y:12}];
dir = {x:1, y:0};
inputQueue = [];
dead = false;
lastTick = 0;
score = 0;
food = spawnFood();
updateScore();
}
function spawnFood() {
while (true) {
const f = { x: Math.floor(Math.random()*GRID), y: Math.floor(Math.random()*GRID) };
const onSnake = snake.some(c => c.x === f.x && c.y === f.y);
if (!onSnake) return f;
}
}
function updateScore() {
score = snake.length - 3;
document.getElementById("score").textContent = score;
}
// --- input: keyboard + touch both feed the SAME queue ---
document.addEventListener("keydown", function(e) {
if (e.key === " " && dead) { reset(); return; }
const map = { ArrowUp:{x:0,y:-1}, ArrowDown:{x:0,y:1},
ArrowLeft:{x:-1,y:0}, ArrowRight:{x:1,y:0} };
if (map[e.key]) inputQueue.push(map[e.key]);
});
let touchStart = null;
canvas.addEventListener("touchstart", function(e) {
touchStart = { x: e.touches[0].clientX, y: e.touches[0].clientY };
e.preventDefault();
});
canvas.addEventListener("touchend", function(e) {
if (!touchStart) return;
const dx = e.changedTouches[0].clientX - touchStart.x;
const dy = e.changedTouches[0].clientY - touchStart.y;
if (Math.abs(dx) > Math.abs(dy)) inputQueue.push({ x: dx > 0 ? 1 : -1, y: 0 });
else inputQueue.push({ x: 0, y: dy > 0 ? 1 : -1 });
touchStart = null;
});
function step() {
// one valid turn per tick
while (inputQueue.length) {
const want = inputQueue.shift();
if (want.x !== -dir.x || want.y !== -dir.y) { dir = want; break; }
}
const head = { x: snake[0].x + dir.x, y: snake[0].y + dir.y };
const wrapMode = document.getElementById("wrap").checked;
if (wrapMode) {
head.x = (head.x + GRID) % GRID;
head.y = (head.y + GRID) % GRID;
} else if (head.x < 0 || head.x > GRID-1 || head.y < 0 || head.y > GRID-1) {
dead = true; return;
}
if (snake.some(c => c.x === head.x && c.y === head.y)) { dead = true; return; }
snake.unshift(head);
if (head.x === food.x && head.y === food.y) { food = spawnFood(); updateScore(); }
else snake.pop();
}
function draw(t) {
ctx.clearRect(0, 0, 500, 500);
// pulsing food
const r = 8 + Math.sin(t/150) * 2;
ctx.fillStyle = "#ff5470";
ctx.beginPath();
ctx.arc(food.x*CELL + CELL/2, food.y*CELL + CELL/2, r, 0, Math.PI*2);
ctx.fill();
// gradient body
snake.forEach(function(c, i) {
const light = 55 - Math.min(35, i*1.5);
ctx.fillStyle = "hsl(155, 70%, " + light + "%)";
ctx.fillRect(c.x*CELL + 1, c.y*CELL + 1, CELL-2, CELL-2);
});
if (dead) {
ctx.fillStyle = "rgba(0,0,0,.6)";
ctx.fillRect(0, 0, 500, 500);
ctx.fillStyle = "#fff";
ctx.font = "bold 34px system-ui";
ctx.textAlign = "center";
ctx.fillText("Game Over", 250, 235);
ctx.font = "18px system-ui";
ctx.fillText("Score " + score + " ยท press Space", 250, 270);
}
}
function loop(t) {
const tickTime = Math.max(60, 120 - snake.length*2);
if (!dead && t - lastTick > tickTime) {
lastTick = t;
step();
if (dead && score > best) {
best = score;
localStorage.setItem("snakeBest", best);
document.getElementById("best").textContent = best;
}
}
draw(t);
requestAnimationFrame(loop);
}
reset();
requestAnimationFrame(loop);
</script>
</body>
</html><!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Pixel Hunter - Snake</title>
<style>
body { display:flex; flex-direction:column; align-items:center; gap:12px;
background:#0d1117; color:#e8eaf2; font-family:system-ui, sans-serif; padding:16px; }
canvas { background:#0a0e14; border:2px solid #1e2733; border-radius:8px; touch-action:none; }
.row { display:flex; gap:16px; align-items:center; font-size:.95rem; }
label { display:flex; gap:6px; align-items:center; cursor:pointer; }
</style>
</head>
<body>
<h2>๐ Pixel Hunter</h2>
<div class="row">
<span>Score: <b id="score">0</b></span>
<span>Best: <b id="best">0</b></span>
<label><input type="checkbox" id="wrap"> Wrap-around walls</label>
</div>
<canvas id="game" width="500" height="500"></canvas>
<div class="row">Arrow keys / swipe to move ยท Space to restart</div>
<script>
const canvas = document.getElementById("game");
const ctx = canvas.getContext("2d");
const CELL = 20, GRID = 25;
let snake, dir, inputQueue, food, dead, lastTick, score, best;
best = Number(localStorage.getItem("snakeBest") || 0);
document.getElementById("best").textContent = best;
function reset() {
snake = [{x:12,y:12}, {x:11,y:12}, {x:10,y:12}];
dir = {x:1, y:0};
inputQueue = [];
dead = false;
lastTick = 0;
score = 0;
food = spawnFood();
updateScore();
}
function spawnFood() {
while (true) {
const f = { x: Math.floor(Math.random()*GRID), y: Math.floor(Math.random()*GRID) };
const onSnake = snake.some(c => c.x === f.x && c.y === f.y);
if (!onSnake) return f;
}
}
function updateScore() {
score = snake.length - 3;
document.getElementById("score").textContent = score;
}
// --- input: keyboard + touch both feed the SAME queue ---
document.addEventListener("keydown", function(e) {
if (e.key === " " && dead) { reset(); return; }
const map = { ArrowUp:{x:0,y:-1}, ArrowDown:{x:0,y:1},
ArrowLeft:{x:-1,y:0}, ArrowRight:{x:1,y:0} };
if (map[e.key]) inputQueue.push(map[e.key]);
});
let touchStart = null;
canvas.addEventListener("touchstart", function(e) {
touchStart = { x: e.touches[0].clientX, y: e.touches[0].clientY };
e.preventDefault();
});
canvas.addEventListener("touchend", function(e) {
if (!touchStart) return;
const dx = e.changedTouches[0].clientX - touchStart.x;
const dy = e.changedTouches[0].clientY - touchStart.y;
if (Math.abs(dx) > Math.abs(dy)) inputQueue.push({ x: dx > 0 ? 1 : -1, y: 0 });
else inputQueue.push({ x: 0, y: dy > 0 ? 1 : -1 });
touchStart = null;
});
function step() {
// one valid turn per tick
while (inputQueue.length) {
const want = inputQueue.shift();
if (want.x !== -dir.x || want.y !== -dir.y) { dir = want; break; }
}
const head = { x: snake[0].x + dir.x, y: snake[0].y + dir.y };
const wrapMode = document.getElementById("wrap").checked;
if (wrapMode) {
head.x = (head.x + GRID) % GRID;
head.y = (head.y + GRID) % GRID;
} else if (head.x < 0 || head.x > GRID-1 || head.y < 0 || head.y > GRID-1) {
dead = true; return;
}
if (snake.some(c => c.x === head.x && c.y === head.y)) { dead = true; return; }
snake.unshift(head);
if (head.x === food.x && head.y === food.y) { food = spawnFood(); updateScore(); }
else snake.pop();
}
function draw(t) {
ctx.clearRect(0, 0, 500, 500);
// pulsing food
const r = 8 + Math.sin(t/150) * 2;
ctx.fillStyle = "#ff5470";
ctx.beginPath();
ctx.arc(food.x*CELL + CELL/2, food.y*CELL + CELL/2, r, 0, Math.PI*2);
ctx.fill();
// gradient body
snake.forEach(function(c, i) {
const light = 55 - Math.min(35, i*1.5);
ctx.fillStyle = "hsl(155, 70%, " + light + "%)";
ctx.fillRect(c.x*CELL + 1, c.y*CELL + 1, CELL-2, CELL-2);
});
if (dead) {
ctx.fillStyle = "rgba(0,0,0,.6)";
ctx.fillRect(0, 0, 500, 500);
ctx.fillStyle = "#fff";
ctx.font = "bold 34px system-ui";
ctx.textAlign = "center";
ctx.fillText("Game Over", 250, 235);
ctx.font = "18px system-ui";
ctx.fillText("Score " + score + " ยท press Space", 250, 270);
}
}
function loop(t) {
const tickTime = Math.max(60, 120 - snake.length*2);
if (!dead && t - lastTick > tickTime) {
lastTick = t;
step();
if (dead && score > best) {
best = score;
localStorage.setItem("snakeBest", best);
document.getElementById("best").textContent = best;
}
}
draw(t);
requestAnimationFrame(loop);
}
reset();
requestAnimationFrame(loop);
</script>
</body>
</html>