Build the classic Snake game as a single React component. A snake crawls a 12x12 grid one cell per tick; arrow keys steer it; eating food grows it and scores a point; running into a wall or your own body ends the game. It's a small game, but it forces two things most timers get wrong: a loop that reads the latest state, and a clean teardown.
The starter App.tsx renders the board in its resting state — a 3-cell snake, one piece of food, score 0, and a Start button — with no logic. Make it playable:
snake (array of {x, y}, head first), food, score, running, and over, all in useState.setInterval(step, 150). Each step computes newHead = head + dir and either ends the game (wall or self), grows (on food), or moves (add head, drop tail).window keydown listener maps arrow keys to a direction — but never one that reverses straight into the neck.[(6,6), (5,6), (4,6)], starting food (9,4), and a rightward starting heading.button with a truthful visible label.keydown sets the direction; the interval, created once, must see that change — a value captured in the interval closure goes stale. Hold the direction in a ref.pop.You'll hold the game in useState, drive one setInterval loop, and — the crux — keep the direction in a ref so the tick always reads the latest heading.
The board is a 12x12 grid. The snake is an array of {x, y} cells, head first. Every 150ms the snake takes one step: a new head appears one cell ahead in the current direction, and the tail usually drops off — unless the head just landed on food, in which case the tail stays and the snake grows. If the new head leaves the board or overlaps the body, the game ends. Arrow keys change the direction, and food respawns on a random empty cell each time it's eaten.
A tick is a pure transform of the game state: newHead = head + dir, then a three-way branch — collision, food, or empty. The board is just a render of snake and food. The one subtlety that trips people up: the loop is created once, but the direction keeps changing. If the interval closure captured dir, it would be frozen at the start value forever. The fix is to read the direction from a ref.
The natural first try puts dir in state and reads it inside the interval:
const [dir, setDir] = useState({ x: 1, y: 0 });
useEffect(() => {
if (!running) return;
const id = setInterval(() => {
setSnake((prev) => step(prev, dir)); // dir captured here
}, 150);
return () => clearInterval(id);
}, [running]); // interval created once
The snake moves right and never turns. The effect runs once (deps are [running]), so the interval closure captured dir at its first value — pressing an arrow updates the state dir, but the running interval still sees the old one. Adding dir to the deps "fixes" the direction but re-creates the interval on every keypress, resetting the 150ms beat. The right tool is a ref: one stable interval, always reading the current value.
import { useState, useRef, useEffect } from 'react';
import './styles.css';
const SIZE = 12;
const START_SNAKE = [{ x: 6, y: 6 }, { x: 5, y: 6 }, { x: 4, y: 6 }];
const START_FOOD = { x: 9, y: 4 };
const START_DIR = { x: 1, y: 0 };
const DIRS = {
ArrowUp: { x: 0, y: -1 },
ArrowDown: { x: 0, y: 1 },
ArrowLeft: { x: -1, y: 0 },
ArrowRight: { x: 1, y: 0 },
};
function randomFood(snake) {
const taken = new Set(snake.map((c) => c.y * SIZE + c.x));
const free = [];
for (let i = 0; i < SIZE * SIZE; i++) if (!taken.has(i)) free.push(i);
const i = free[Math.floor(Math.random() * free.length)];
return { x: i % SIZE, y: Math.floor(i / SIZE) };
}
export default function App() {
const [snake, setSnake] = useState(START_SNAKE);
const [food, setFood] = useState(START_FOOD);
const [score, setScore] = useState(0);
const [running, setRunning] = useState(false);
const [over, setOver] = useState(false);
const [gameId, setGameId] = useState(0);
// Mirrors read inside the interval so the tick never sees stale state.
const snakeRef = useRef(snake);
snakeRef.current = snake;
const foodRef = useRef(food);
foodRef.current = food;
const headingRef = useRef(START_DIR); // the true heading; updated at each tick
const queuedRef = useRef(START_DIR); // next direction from the keyboard
const runningRef = useRef(running);
runningRef.current = running;
useEffect(() => {
if (!running) return;
const id = setInterval(() => {
const d = queuedRef.current;
headingRef.current = d;
const prev = snakeRef.current;
const head = { x: prev[0].x + d.x, y: prev[0].y + d.y };
const offBoard = head.x < 0 || head.x >= SIZE || head.y < 0 || head.y >= SIZE;
const hitSelf = prev.some((c) => c.x === head.x && c.y === head.y);
if (offBoard || hitSelf) {
setRunning(false);
setOver(true);
return;
}
const next = [head, ...prev];
if (head.x === foodRef.current.x && head.y === foodRef.current.y) {
setScore((s) => s + 1);
setFood(randomFood(next));
} else {
next.pop();
}
setSnake(next);
}, 150);
return () => clearInterval(id);
}, [running, gameId]);
useEffect(() => {
function onKey(e) {
const nd = DIRS[e.key];
if (!nd || !runningRef.current) return;
e.preventDefault();
const h = headingRef.current;
if (nd.x === -h.x && nd.y === -h.y) return; // can't reverse into the neck
queuedRef.current = nd;
}
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, []);
function start() {
setSnake(START_SNAKE);
setFood(START_FOOD);
setScore(0);
setOver(false);
headingRef.current = START_DIR;
queuedRef.current = START_DIR;
setGameId((id) => id + 1);
setRunning(true);
}
function cellClass(x, y) {
if (snake[0].x === x && snake[0].y === y) return 'cell head';
if (snake.some((c) => c.x === x && c.y === y)) return 'cell snake';
if (food.x === x && food.y === y) return 'cell food';
return 'cell';
}
const status = over
? `Game over. Final score ${score}. Press Restart to play again.`
: running
? 'Game running. Use the arrow keys to steer.'
: 'Ready. Press Start, then use the arrow keys to steer.';
return (
<main className="container">
<h1>Snake Game</h1>
<div className="hud">
<span className="score" aria-live="polite" aria-atomic="true">
Score: {score}
</span>
<button type="button" className="btn" onClick={start}>
{running || over ? 'Restart' : 'Start'}
</button>
</div>
<p id="game-status" className="sr-only" aria-live="polite">
{status}
</p>
<div
className="board"
role="img"
aria-label="12 by 12 Snake game board"
aria-describedby="game-status"
>
{Array.from({ length: SIZE * SIZE }, (_, i) => (
<div
key={i}
className={cellClass(i % SIZE, Math.floor(i / SIZE))}
aria-hidden="true"
/>
))}
{over && <div className="overlay">Game Over</div>}
</div>
</main>
);
}
The interval is created once per game (deps [running]) and reads everything through refs — snakeRef, foodRef, and the direction — so it always acts on the current state. setScore/setFood/setSnake are the only state writes; the board renders from snake and food.
Direction has one trap. A keydown handler registered in a [] effect runs once, so if it compared against a captured dir it would compare against the initial direction forever. Instead it reads headingRef.current — the real current heading — and rejects any reverse. Turns are queued: the tick consumes queuedRef and commits it to headingRef, so even a fast double-tap can't sneak a reversal through.
Walking one turn: heading is right, you press Up. Up isn't the reverse of right, so queuedRef becomes up. On the next tick the snake reads up, moves up, and headingRef becomes up. Press Down now and it's rejected — down reverses up.
dir/snake/food freezes at its first value. Read them from refs (updated every render) so the tick sees the latest.clearInterval from the effect and clear it on game over. Without teardown, Start-Start or an unmount leaves a ghost loop mutating state.(x + SIZE) % SIZE) instead of ending the game — a different, forgiving mode.localStorage on game over and show a "best" next to the current score.One immutable state object makes every tick an explicit snapshot transition while refs keep the timer and keyboard synchronous.
import { useEffect, useRef, useState } from 'react'; import './styles.css';
type Cell = { x: number; y: number }; type Game = { snake: Cell[]; food: Cell; score: number; running: boolean; over: boolean };
const SIZE = 12, START_SNAKE = [{ x: 6, y: 6 }, { x: 5, y: 6 }, { x: 4, y: 6 }], START_FOOD = { x: 9, y: 4 }, START_DIR = { x: 1, y: 0 };
const DIRS: Record<string, Cell> = { ArrowUp: { x: 0, y: -1 }, ArrowDown: { x: 0, y: 1 }, ArrowLeft: { x: -1, y: 0 }, ArrowRight: { x: 1, y: 0 } };
const fresh = (): Game => ({ snake: START_SNAKE.map((cell) => ({ ...cell })), food: { ...START_FOOD }, score: 0, running: false, over: false });
function newFood(body: Cell[]) { const taken = new Set(body.map((cell) => cell.y * SIZE + cell.x)); const free = Array.from({ length: SIZE * SIZE }, (_, index) => index).filter((index) => !taken.has(index)); const index = free[Math.floor(Math.random() * free.length)]; return { x: index % SIZE, y: Math.floor(index / SIZE) }; }
function useGame() { const [game, setGame] = useState(fresh); const [round, setRound] = useState(0); const gameRef = useRef(game); gameRef.current = game; const heading = useRef({ ...START_DIR }); const queued = useRef({ ...START_DIR });
const start = () => { heading.current = { ...START_DIR }; queued.current = { ...START_DIR }; const next = { ...fresh(), running: true }; gameRef.current = next; setGame(next); setRound((value) => value + 1); };
useEffect(() => { if (!game.running) return; const timer = setInterval(() => { const previous = gameRef.current; const direction = queued.current; heading.current = direction; const head = { x: previous.snake[0].x + direction.x, y: previous.snake[0].y + direction.y }; if (head.x < 0 || head.x >= SIZE || head.y < 0 || head.y >= SIZE || previous.snake.some((cell) => cell.x === head.x && cell.y === head.y)) { const next = { ...previous, running: false, over: true }; gameRef.current = next; setGame(next); return; } const snake = [head, ...previous.snake]; let food = previous.food, score = previous.score; if (head.x === food.x && head.y === food.y) { score += 1; food = newFood(snake); } else snake.pop(); const next = { ...previous, snake, food, score }; gameRef.current = next; setGame(next); }, 150); return () => clearInterval(timer); }, [game.running, round]);
useEffect(() => { const key = (event: KeyboardEvent) => { const direction = DIRS[event.key]; if (!direction || !gameRef.current.running) return; event.preventDefault(); const current = heading.current; if (direction.x === -current.x && direction.y === -current.y) return; queued.current = direction; }; window.addEventListener('keydown', key); return () => window.removeEventListener('keydown', key); }, []); return { game, start }; }
export default function App() { const { game, start } = useGame(); const cls = (x: number, y: number) => game.snake[0].x === x && game.snake[0].y === y ? 'cell head' : game.snake.some((cell) => cell.x === x && cell.y === y) ? 'cell snake' : game.food.x === x && game.food.y === y ? 'cell food' : 'cell'; const status = game.over ? `Game over. Final score ${game.score}. Press Restart to play again.` : game.running ? 'Game running. Use the arrow keys to steer.' : 'Ready. Press Start, then use the arrow keys to steer.'; return <main className="container"><h1>Snake Game</h1><div className="hud"><span className="score" aria-live="polite" aria-atomic="true">Score: {game.score}</span><button type="button" className="btn" onClick={start}>{game.running || game.over ? 'Restart' : 'Start'}</button></div><p id="game-status" className="sr-only" aria-live="polite">{status}</p><div className="board" role="img" aria-label="12 by 12 Snake game board" aria-describedby="game-status">{Array.from({ length: SIZE * SIZE }, (_, index) => <div key={index} className={cls(index % SIZE, Math.floor(index / SIZE))} aria-hidden="true"/>)}{game.over && <div className="overlay">Game Over</div>}</div></main>; }A dedicated hook contains the timer engine and direction queue, leaving the component to render its returned model.
import { useEffect, useRef, useState } from 'react'; import './styles.css';
type Cell = { x: number; y: number }; const SIZE = 12, BODY = [{ x: 6, y: 6 }, { x: 5, y: 6 }, { x: 4, y: 6 }], FOOD = { x: 9, y: 4 }, RIGHT = { x: 1, y: 0 }; const arrows: Record<string, Cell> = { ArrowUp: { x: 0, y: -1 }, ArrowDown: { x: 0, y: 1 }, ArrowLeft: { x: -1, y: 0 }, ArrowRight: RIGHT };
function place(body: Cell[]) { const used = new Set(body.map((cell) => cell.y * SIZE + cell.x)); const free = Array.from({ length: SIZE * SIZE }, (_, i) => i).filter((i) => !used.has(i)); const i = free[Math.floor(Math.random() * free.length)]; return { x: i % SIZE, y: Math.floor(i / SIZE) }; }
function useSnake() { const [snake, setSnake] = useState(BODY); const [food, setFood] = useState(FOOD); const [score, setScore] = useState(0); const [running, setRunning] = useState(false); const [over, setOver] = useState(false); const [round, setRound] = useState(0); const liveSnake = useRef(snake), liveFood = useRef(food), heading = useRef(RIGHT), queued = useRef(RIGHT), active = useRef(running); liveSnake.current = snake; liveFood.current = food; active.current = running;
useEffect(() => { if (!running) return; const timer = setInterval(() => { const direction = queued.current; heading.current = direction; const old = liveSnake.current; const head = { x: old[0].x + direction.x, y: old[0].y + direction.y }; if (head.x < 0 || head.x >= SIZE || head.y < 0 || head.y >= SIZE || old.some((cell) => cell.x === head.x && cell.y === head.y)) { setRunning(false); setOver(true); return; } const next = [head, ...old]; if (head.x === liveFood.current.x && head.y === liveFood.current.y) { setScore((value) => value + 1); setFood(place(next)); } else next.pop(); setSnake(next); }, 150); return () => clearInterval(timer); }, [running, round]);
useEffect(() => { const onKey = (event: KeyboardEvent) => { const next = arrows[event.key]; if (!next || !active.current) return; event.preventDefault(); const current = heading.current; if (next.x === -current.x && next.y === -current.y) return; queued.current = next; }; addEventListener('keydown', onKey); return () => removeEventListener('keydown', onKey); }, []);
const start = () => { setSnake(BODY.map((cell) => ({ ...cell }))); setFood({ ...FOOD }); setScore(0); setOver(false); heading.current = { ...RIGHT }; queued.current = { ...RIGHT }; setRound((value) => value + 1); setRunning(true); }; return { snake, food, score, running, over, start }; }
export default function App() { const game = useSnake(); const cls = (x: number, y: number) => game.snake[0].x === x && game.snake[0].y === y ? 'cell head' : game.snake.some((cell) => cell.x === x && cell.y === y) ? 'cell snake' : game.food.x === x && game.food.y === y ? 'cell food' : 'cell'; const status = game.over ? `Game over. Final score ${game.score}. Press Restart to play again.` : game.running ? 'Game running. Use the arrow keys to steer.' : 'Ready. Press Start, then use the arrow keys to steer.'; return <main className="container"><h1>Snake Game</h1><div className="hud"><span className="score" aria-live="polite" aria-atomic="true">Score: {game.score}</span><button type="button" className="btn" onClick={game.start}>{game.running || game.over ? 'Restart' : 'Start'}</button></div><p id="game-status" className="sr-only" aria-live="polite">{status}</p><div className="board" role="img" aria-label="12 by 12 Snake game board" aria-describedby="game-status">{Array.from({ length: SIZE * SIZE }, (_, i) => <div key={i} className={cls(i % SIZE, Math.floor(i / SIZE))} aria-hidden="true"/>)}{game.over && <div className="overlay">Game Over</div>}</div></main>; }Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.
Build the classic Snake game as a single React component. A snake crawls a 12x12 grid one cell per tick; arrow keys steer it; eating food grows it and scores a point; running into a wall or your own body ends the game. It's a small game, but it forces two things most timers get wrong: a loop that reads the latest state, and a clean teardown.
The starter App.tsx renders the board in its resting state — a 3-cell snake, one piece of food, score 0, and a Start button — with no logic. Make it playable:
snake (array of {x, y}, head first), food, score, running, and over, all in useState.setInterval(step, 150). Each step computes newHead = head + dir and either ends the game (wall or self), grows (on food), or moves (add head, drop tail).window keydown listener maps arrow keys to a direction — but never one that reverses straight into the neck.[(6,6), (5,6), (4,6)], starting food (9,4), and a rightward starting heading.button with a truthful visible label.keydown sets the direction; the interval, created once, must see that change — a value captured in the interval closure goes stale. Hold the direction in a ref.pop.You'll hold the game in useState, drive one setInterval loop, and — the crux — keep the direction in a ref so the tick always reads the latest heading.
The board is a 12x12 grid. The snake is an array of {x, y} cells, head first. Every 150ms the snake takes one step: a new head appears one cell ahead in the current direction, and the tail usually drops off — unless the head just landed on food, in which case the tail stays and the snake grows. If the new head leaves the board or overlaps the body, the game ends. Arrow keys change the direction, and food respawns on a random empty cell each time it's eaten.
A tick is a pure transform of the game state: newHead = head + dir, then a three-way branch — collision, food, or empty. The board is just a render of snake and food. The one subtlety that trips people up: the loop is created once, but the direction keeps changing. If the interval closure captured dir, it would be frozen at the start value forever. The fix is to read the direction from a ref.
The natural first try puts dir in state and reads it inside the interval:
const [dir, setDir] = useState({ x: 1, y: 0 });
useEffect(() => {
if (!running) return;
const id = setInterval(() => {
setSnake((prev) => step(prev, dir)); // dir captured here
}, 150);
return () => clearInterval(id);
}, [running]); // interval created once
The snake moves right and never turns. The effect runs once (deps are [running]), so the interval closure captured dir at its first value — pressing an arrow updates the state dir, but the running interval still sees the old one. Adding dir to the deps "fixes" the direction but re-creates the interval on every keypress, resetting the 150ms beat. The right tool is a ref: one stable interval, always reading the current value.
import { useState, useRef, useEffect } from 'react';
import './styles.css';
const SIZE = 12;
const START_SNAKE = [{ x: 6, y: 6 }, { x: 5, y: 6 }, { x: 4, y: 6 }];
const START_FOOD = { x: 9, y: 4 };
const START_DIR = { x: 1, y: 0 };
const DIRS = {
ArrowUp: { x: 0, y: -1 },
ArrowDown: { x: 0, y: 1 },
ArrowLeft: { x: -1, y: 0 },
ArrowRight: { x: 1, y: 0 },
};
function randomFood(snake) {
const taken = new Set(snake.map((c) => c.y * SIZE + c.x));
const free = [];
for (let i = 0; i < SIZE * SIZE; i++) if (!taken.has(i)) free.push(i);
const i = free[Math.floor(Math.random() * free.length)];
return { x: i % SIZE, y: Math.floor(i / SIZE) };
}
export default function App() {
const [snake, setSnake] = useState(START_SNAKE);
const [food, setFood] = useState(START_FOOD);
const [score, setScore] = useState(0);
const [running, setRunning] = useState(false);
const [over, setOver] = useState(false);
const [gameId, setGameId] = useState(0);
// Mirrors read inside the interval so the tick never sees stale state.
const snakeRef = useRef(snake);
snakeRef.current = snake;
const foodRef = useRef(food);
foodRef.current = food;
const headingRef = useRef(START_DIR); // the true heading; updated at each tick
const queuedRef = useRef(START_DIR); // next direction from the keyboard
const runningRef = useRef(running);
runningRef.current = running;
useEffect(() => {
if (!running) return;
const id = setInterval(() => {
const d = queuedRef.current;
headingRef.current = d;
const prev = snakeRef.current;
const head = { x: prev[0].x + d.x, y: prev[0].y + d.y };
const offBoard = head.x < 0 || head.x >= SIZE || head.y < 0 || head.y >= SIZE;
const hitSelf = prev.some((c) => c.x === head.x && c.y === head.y);
if (offBoard || hitSelf) {
setRunning(false);
setOver(true);
return;
}
const next = [head, ...prev];
if (head.x === foodRef.current.x && head.y === foodRef.current.y) {
setScore((s) => s + 1);
setFood(randomFood(next));
} else {
next.pop();
}
setSnake(next);
}, 150);
return () => clearInterval(id);
}, [running, gameId]);
useEffect(() => {
function onKey(e) {
const nd = DIRS[e.key];
if (!nd || !runningRef.current) return;
e.preventDefault();
const h = headingRef.current;
if (nd.x === -h.x && nd.y === -h.y) return; // can't reverse into the neck
queuedRef.current = nd;
}
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, []);
function start() {
setSnake(START_SNAKE);
setFood(START_FOOD);
setScore(0);
setOver(false);
headingRef.current = START_DIR;
queuedRef.current = START_DIR;
setGameId((id) => id + 1);
setRunning(true);
}
function cellClass(x, y) {
if (snake[0].x === x && snake[0].y === y) return 'cell head';
if (snake.some((c) => c.x === x && c.y === y)) return 'cell snake';
if (food.x === x && food.y === y) return 'cell food';
return 'cell';
}
const status = over
? `Game over. Final score ${score}. Press Restart to play again.`
: running
? 'Game running. Use the arrow keys to steer.'
: 'Ready. Press Start, then use the arrow keys to steer.';
return (
<main className="container">
<h1>Snake Game</h1>
<div className="hud">
<span className="score" aria-live="polite" aria-atomic="true">
Score: {score}
</span>
<button type="button" className="btn" onClick={start}>
{running || over ? 'Restart' : 'Start'}
</button>
</div>
<p id="game-status" className="sr-only" aria-live="polite">
{status}
</p>
<div
className="board"
role="img"
aria-label="12 by 12 Snake game board"
aria-describedby="game-status"
>
{Array.from({ length: SIZE * SIZE }, (_, i) => (
<div
key={i}
className={cellClass(i % SIZE, Math.floor(i / SIZE))}
aria-hidden="true"
/>
))}
{over && <div className="overlay">Game Over</div>}
</div>
</main>
);
}
The interval is created once per game (deps [running]) and reads everything through refs — snakeRef, foodRef, and the direction — so it always acts on the current state. setScore/setFood/setSnake are the only state writes; the board renders from snake and food.
Direction has one trap. A keydown handler registered in a [] effect runs once, so if it compared against a captured dir it would compare against the initial direction forever. Instead it reads headingRef.current — the real current heading — and rejects any reverse. Turns are queued: the tick consumes queuedRef and commits it to headingRef, so even a fast double-tap can't sneak a reversal through.
Walking one turn: heading is right, you press Up. Up isn't the reverse of right, so queuedRef becomes up. On the next tick the snake reads up, moves up, and headingRef becomes up. Press Down now and it's rejected — down reverses up.
dir/snake/food freezes at its first value. Read them from refs (updated every render) so the tick sees the latest.clearInterval from the effect and clear it on game over. Without teardown, Start-Start or an unmount leaves a ghost loop mutating state.(x + SIZE) % SIZE) instead of ending the game — a different, forgiving mode.localStorage on game over and show a "best" next to the current score.One immutable state object makes every tick an explicit snapshot transition while refs keep the timer and keyboard synchronous.
import { useEffect, useRef, useState } from 'react'; import './styles.css';
type Cell = { x: number; y: number }; type Game = { snake: Cell[]; food: Cell; score: number; running: boolean; over: boolean };
const SIZE = 12, START_SNAKE = [{ x: 6, y: 6 }, { x: 5, y: 6 }, { x: 4, y: 6 }], START_FOOD = { x: 9, y: 4 }, START_DIR = { x: 1, y: 0 };
const DIRS: Record<string, Cell> = { ArrowUp: { x: 0, y: -1 }, ArrowDown: { x: 0, y: 1 }, ArrowLeft: { x: -1, y: 0 }, ArrowRight: { x: 1, y: 0 } };
const fresh = (): Game => ({ snake: START_SNAKE.map((cell) => ({ ...cell })), food: { ...START_FOOD }, score: 0, running: false, over: false });
function newFood(body: Cell[]) { const taken = new Set(body.map((cell) => cell.y * SIZE + cell.x)); const free = Array.from({ length: SIZE * SIZE }, (_, index) => index).filter((index) => !taken.has(index)); const index = free[Math.floor(Math.random() * free.length)]; return { x: index % SIZE, y: Math.floor(index / SIZE) }; }
function useGame() { const [game, setGame] = useState(fresh); const [round, setRound] = useState(0); const gameRef = useRef(game); gameRef.current = game; const heading = useRef({ ...START_DIR }); const queued = useRef({ ...START_DIR });
const start = () => { heading.current = { ...START_DIR }; queued.current = { ...START_DIR }; const next = { ...fresh(), running: true }; gameRef.current = next; setGame(next); setRound((value) => value + 1); };
useEffect(() => { if (!game.running) return; const timer = setInterval(() => { const previous = gameRef.current; const direction = queued.current; heading.current = direction; const head = { x: previous.snake[0].x + direction.x, y: previous.snake[0].y + direction.y }; if (head.x < 0 || head.x >= SIZE || head.y < 0 || head.y >= SIZE || previous.snake.some((cell) => cell.x === head.x && cell.y === head.y)) { const next = { ...previous, running: false, over: true }; gameRef.current = next; setGame(next); return; } const snake = [head, ...previous.snake]; let food = previous.food, score = previous.score; if (head.x === food.x && head.y === food.y) { score += 1; food = newFood(snake); } else snake.pop(); const next = { ...previous, snake, food, score }; gameRef.current = next; setGame(next); }, 150); return () => clearInterval(timer); }, [game.running, round]);
useEffect(() => { const key = (event: KeyboardEvent) => { const direction = DIRS[event.key]; if (!direction || !gameRef.current.running) return; event.preventDefault(); const current = heading.current; if (direction.x === -current.x && direction.y === -current.y) return; queued.current = direction; }; window.addEventListener('keydown', key); return () => window.removeEventListener('keydown', key); }, []); return { game, start }; }
export default function App() { const { game, start } = useGame(); const cls = (x: number, y: number) => game.snake[0].x === x && game.snake[0].y === y ? 'cell head' : game.snake.some((cell) => cell.x === x && cell.y === y) ? 'cell snake' : game.food.x === x && game.food.y === y ? 'cell food' : 'cell'; const status = game.over ? `Game over. Final score ${game.score}. Press Restart to play again.` : game.running ? 'Game running. Use the arrow keys to steer.' : 'Ready. Press Start, then use the arrow keys to steer.'; return <main className="container"><h1>Snake Game</h1><div className="hud"><span className="score" aria-live="polite" aria-atomic="true">Score: {game.score}</span><button type="button" className="btn" onClick={start}>{game.running || game.over ? 'Restart' : 'Start'}</button></div><p id="game-status" className="sr-only" aria-live="polite">{status}</p><div className="board" role="img" aria-label="12 by 12 Snake game board" aria-describedby="game-status">{Array.from({ length: SIZE * SIZE }, (_, index) => <div key={index} className={cls(index % SIZE, Math.floor(index / SIZE))} aria-hidden="true"/>)}{game.over && <div className="overlay">Game Over</div>}</div></main>; }A dedicated hook contains the timer engine and direction queue, leaving the component to render its returned model.
import { useEffect, useRef, useState } from 'react'; import './styles.css';
type Cell = { x: number; y: number }; const SIZE = 12, BODY = [{ x: 6, y: 6 }, { x: 5, y: 6 }, { x: 4, y: 6 }], FOOD = { x: 9, y: 4 }, RIGHT = { x: 1, y: 0 }; const arrows: Record<string, Cell> = { ArrowUp: { x: 0, y: -1 }, ArrowDown: { x: 0, y: 1 }, ArrowLeft: { x: -1, y: 0 }, ArrowRight: RIGHT };
function place(body: Cell[]) { const used = new Set(body.map((cell) => cell.y * SIZE + cell.x)); const free = Array.from({ length: SIZE * SIZE }, (_, i) => i).filter((i) => !used.has(i)); const i = free[Math.floor(Math.random() * free.length)]; return { x: i % SIZE, y: Math.floor(i / SIZE) }; }
function useSnake() { const [snake, setSnake] = useState(BODY); const [food, setFood] = useState(FOOD); const [score, setScore] = useState(0); const [running, setRunning] = useState(false); const [over, setOver] = useState(false); const [round, setRound] = useState(0); const liveSnake = useRef(snake), liveFood = useRef(food), heading = useRef(RIGHT), queued = useRef(RIGHT), active = useRef(running); liveSnake.current = snake; liveFood.current = food; active.current = running;
useEffect(() => { if (!running) return; const timer = setInterval(() => { const direction = queued.current; heading.current = direction; const old = liveSnake.current; const head = { x: old[0].x + direction.x, y: old[0].y + direction.y }; if (head.x < 0 || head.x >= SIZE || head.y < 0 || head.y >= SIZE || old.some((cell) => cell.x === head.x && cell.y === head.y)) { setRunning(false); setOver(true); return; } const next = [head, ...old]; if (head.x === liveFood.current.x && head.y === liveFood.current.y) { setScore((value) => value + 1); setFood(place(next)); } else next.pop(); setSnake(next); }, 150); return () => clearInterval(timer); }, [running, round]);
useEffect(() => { const onKey = (event: KeyboardEvent) => { const next = arrows[event.key]; if (!next || !active.current) return; event.preventDefault(); const current = heading.current; if (next.x === -current.x && next.y === -current.y) return; queued.current = next; }; addEventListener('keydown', onKey); return () => removeEventListener('keydown', onKey); }, []);
const start = () => { setSnake(BODY.map((cell) => ({ ...cell }))); setFood({ ...FOOD }); setScore(0); setOver(false); heading.current = { ...RIGHT }; queued.current = { ...RIGHT }; setRound((value) => value + 1); setRunning(true); }; return { snake, food, score, running, over, start }; }
export default function App() { const game = useSnake(); const cls = (x: number, y: number) => game.snake[0].x === x && game.snake[0].y === y ? 'cell head' : game.snake.some((cell) => cell.x === x && cell.y === y) ? 'cell snake' : game.food.x === x && game.food.y === y ? 'cell food' : 'cell'; const status = game.over ? `Game over. Final score ${game.score}. Press Restart to play again.` : game.running ? 'Game running. Use the arrow keys to steer.' : 'Ready. Press Start, then use the arrow keys to steer.'; return <main className="container"><h1>Snake Game</h1><div className="hud"><span className="score" aria-live="polite" aria-atomic="true">Score: {game.score}</span><button type="button" className="btn" onClick={game.start}>{game.running || game.over ? 'Restart' : 'Start'}</button></div><p id="game-status" className="sr-only" aria-live="polite">{status}</p><div className="board" role="img" aria-label="12 by 12 Snake game board" aria-describedby="game-status">{Array.from({ length: SIZE * SIZE }, (_, i) => <div key={i} className={cls(i % SIZE, Math.floor(i / SIZE))} aria-hidden="true"/>)}{game.over && <div className="overlay">Game Over</div>}</div></main>; }Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.