Hey all! How would you model a snake game with React Hooks? Here's how I would do it! (You can play here! and Edit here!)
The main things to look at:
- This is written in Typescript, to assist the gist also contains the same code in JavaScript.
- The types!
- Snake takes place on a two dimensional grid, so we represent locations on that grid with a
Coordinate
type, which is a tuple of two numbers representing the x and y positions - The Snake itself is represented in two ways:
-
Snake
: An array of coordinate arrays -
SnakeMap
: A sort of Trie built from the coordinates - Why both? Do you want to know where the snake is, or if the snake is in a specific place? You'll probably need to know both things at different times, so we create two structures that efficiently answer each question.
-
- The
Game
is a combination of all the data about the snake, plus:- a coordinate for the
food
- the
score
- and an
alive
boolean
- a coordinate for the
- Finally, we need a direction to move in, so we have a union type of single characters for each cardinal
Direction
- Snake takes place on a two dimensional grid, so we represent locations on that grid with a
- There are three important hooks our
useSnake
hook calls:- First: It calls
useReducer
, reducing aGame
from aDirection
. This is an action packed reducer, which is the logic behind themoveSnake
function. - Second: It calls
useRef
to create a persistent object to store our direction. WhyuseRef
instead ofuseState
? Because we don't want to re-render every time you hit a direction key, which updating a stateful value would do. We also useuseCallback
here to create a persistent callback to update the ref. - Third: It calls
useEffect
, which (assuming the snake is alive) sets up the interval which forms our game loop. What do we do in the loop? Move the snake in the current direction!
- First: It calls
If snakes or games aren't your thing, but you like this style, leave a comment with what hooks snippet I should write next!
import { useReducer, useEffect, useRef, useCallback } from 'react' | |
type Coordinate = [number, number] | |
type Snake = Coordinate[] | |
type SnakeMap = { | |
[key: number]: { | |
[key: number]: true | |
} | |
} | |
type Game = { | |
alive: boolean | |
snake: Snake | |
snakeMap: SnakeMap | |
food: Coordinate | null | |
score: number | |
} | |
type Direction = 'n' | 's' | 'e' | 'w' | |
const offsets: Record<Direction, Coordinate> = { | |
n: [0, 1], | |
s: [0, -1], | |
e: [1, 0], | |
w: [-1, 0], | |
} | |
const equals = (a: Coordinate, b: Coordinate) => | |
a[0] === b[0] && a[1] === b[1] | |
const randomIndex = (length: number) => | |
Math.trunc(Math.random() * length) | |
const addToSnakeMap = (map: SnakeMap, [x, y]: Coordinate) => { | |
map[x] = map[x] || {} | |
map[x][y] = true | |
} | |
const removeFromSnakeMap = (map: SnakeMap, [x, y]: Coordinate) => { | |
const row = map[x] | |
delete row[y] | |
} | |
export function useSnake(boardSize: number, speed: number) { | |
const start = Math.trunc(boardSize / 2) | |
const [game, moveSnake] = useReducer( | |
( | |
{ snake, snakeMap, alive, food, score }: Game, | |
direction: Direction, | |
) => { | |
const [[x, y]] = snake | |
const [dX, dY] = offsets[direction] | |
const head: Coordinate = [x + dX, y + dY] | |
if ( | |
head.find(coord => coord < 0 || coord >= boardSize) || | |
snake.find(part => equals(part, head)) | |
) { | |
alive = false | |
} else { | |
snake = [head, ...snake] | |
addToSnakeMap(snakeMap, head) | |
if (food && equals(food, head)) { | |
score++ | |
food = null | |
} else { | |
removeFromSnakeMap(snakeMap, snake.pop()) | |
if (food === null && Math.random() > 1 / 3) { | |
const openCoords: Coordinate[] = [] | |
for (let i = 0; i < boardSize; i++) { | |
for (let j = 0; j < boardSize; j++) { | |
if (!snakeMap[i] || !snakeMap[i][j]) { | |
openCoords.push([i, j]) | |
} | |
} | |
} | |
food = openCoords[randomIndex(openCoords.length)] | |
} | |
} | |
} | |
return { | |
snake, | |
snakeMap, | |
alive, | |
food, | |
score, | |
} | |
}, | |
{ | |
snake: [[start, start]], | |
snakeMap: { | |
[start]: { | |
[start]: true, | |
}, | |
}, | |
alive: true, | |
food: null, | |
score: 0, | |
}, | |
) | |
const directionRef = useRef<Direction>('s') | |
const updateDirection = useCallback( | |
(dir: Direction) => (directionRef.current = dir), | |
[directionRef], | |
) | |
const { alive } = game | |
useEffect(() => { | |
if (alive) { | |
const interval = setInterval(() => { | |
moveSnake(directionRef.current) | |
}, speed) | |
return () => clearInterval(interval) | |
} | |
}, [speed, alive]) | |
return { | |
updateDirection, | |
direction: directionRef.current, | |
...game, | |
} | |
} |
Top comments (0)