ReactJS — Build a drunken snake game using hooks

Get Hooked

NOTE: You are reading this article as it is being drafted. Once the article has completed this note will be removed from here.

Before we proceed to build this game, take a look at the final output.

Pre-requisites

  • Basic knowledge of React and React Hooks
  • Good knowledge of JavaScript

Game Logic

The game consists of 20 by 20 cells (This can be configured to make the playing grid bigger or smaller).

There are no moving objects in the game. All grid cells are positioned stationary and based on the state of the game the respective cell is rendered as below.

  • Empty Cell
  • Snake Head
  • Snake Tail
  • Apple (food)

Let the code begin

So, now let's get the ball rolling and code the game from scratch and I will not be going in details on CSS but only the required essentials, but you can always refer the code.

Let’s divide the applications into functional components and let us begin by creating a Timer component that displays the game’s running time.

Assuming you already have a react app bootstrapped either manually or by using npx create-react-app or please feel free to follow on codepen.

First the

<div id="root"></div>

For this, I will use the useTimer helper created by https://twitter.com/dan_abramov

The useInterval custom Hook

I’ll pull in the needed API’s for hooks.

const {useEffect, useState, useRef, useReducer} = React;
OR
// import {useEffect, useState, useRef, useReducer} from 'react'.
/* Thanks Dan Abramov  for useInterval hook
https://overreacted.io/making-setinterval-declarative-with-react-hooks/
*/
function useInterval(callback, delay) {
const savedCallback = useRef();
  useEffect(() => {
savedCallback.current = callback;
});
  useEffect(() => {
function tick() {
savedCallback.current();
}
let id = setInterval(tick, delay);
return () => clearInterval(id);
}, [delay]);
}

Step 1: Timer Component

// Timer component
function Timer({pause}) {
const [hour, setHours] = useState(0);
const [minute, setMinutes] = useState(0);
const [second, setSeconds] = useState(0);
  const toTime = (time) => ("0" + time).slice(-2);

let resetRef = useRef();
// Trick to Intialize countRef.current on first render only.
resetRef.current = resetRef.current || false;

useEffect(() => {
if (resetRef.current === true) {
setSeconds(0);
}
});

useInterval(()=> {
if (pause) {
resetRef.current = true;
return;
}
resetRef.current = false;
setSeconds(second + 1);
}, pause ? null : 1000);
  useInterval(()=> {
if (pause) {
resetRef.current = true;
return;
}
resetRef.current = false;
setSeconds(0);
setMinutes(minute + 1);
}, pause ? null : 1000 * 60);
  useInterval(()=> {
if (pause) {
resetRef.current = true;
return;
}
setSeconds(0);
setMinutes(0);
setHours(hour + 1);
}, pause ? null : 1000 * 60 * 60);
  return (
<div className="timer">
<span>TIME:</span> <span>{toTime(hour)}:</span>
<span>{toTime(minute)}:</span>
<span>{toTime(second)}</span>
</div>
);
}

The Counter components need three timers for hours, minutes and seconds. Each of these three timers is configured in the useInterval custom hook. These hooks will be executed in the order of creation.

The states of each of the timer are controlled by the useState as shown below.

const [hour, setHours] = useState(0);
const [minute, setMinutes] = useState(0);
const [second, setSeconds] = useState(0);

The first timer is executed every 1 second (1000 ms)
The second timer is executed after every 1 minute (1000 X 60)
The third timer is executed after every 1 hour (1000 * 60 * 60)

To test the Counter Component add the below code.

const rootElement = document.querySelector("#root");
ReactDOM.render(<Timer pause={true} />, rootElement);

The Step 1 working code is here https://codepen.io/rajeshpillai/pen/zQbZJY?editors=0010

The output of the above program is shown below when the timer is paused.

To make the timer running change the code and pass ‘false’ to the pause prop.

const rootElement = document.querySelector("#root");
ReactDOM.render(<Timer pause={false} />, rootElement);

The output will be somewhat as shown in the below figure.

Step 2: Drawing the Grid

First, let us define the state model for our game by creating a function that returns the default state.

function initState () {
const grid = initGrid();
return {
grid,
snake: {
head: {
row: 5,
col: 9,
},
tail:[],
},
food: {
row: Math.floor(Math.random() * 5),
col: Math.floor(Math.random() * 5),
},
score: 0,
showGrid: true,
lost: false,
message: 'Press <space> or touch/click to start the game',
inprogress: false,
}
}

The state model is quite intuitive. The actual grid model is built within the initGrid() function.

function initGrid () {
const grid = [];
for (let row = 0; row <20; row++) {
const cols = [];
for (let col = 0; col < 20; col ++) {
cols.push({
row,
col
});
}
grid.push(cols);
}
return grid;
}

The initGrid function creates a 20 by 20 cell objects, which contains the row as well as col of the specific cell.

In a nutshell, the grid is nothing but an array of rows, each row containing 20 cells.

Let us also create a helper function to get a random number.

const random = () => {
return Math.random();
};

Math.random() functions return a value between 0 and 1.

Let us now render the grid.

function App() {
// todo: write the reducer
const [state, dispatch] = useReducer(null,initState());
console.log("state: ", state);
  // Generate grid based on row X col
drawGrid = () => {
const {grid} = state;
return (
grid.map((row, i) => {
return row.map(cell => {
return <div key={cell.row+cell.col}
className="cell cell-border" />
});
})
);
}
return (
<div className="game">
<div className="grid-container">
<div className="grid">
{drawGrid()}
</div>
</div>
</div>
)
}

To render the above app, write down the below code.

const rootElement = document.querySelector("#root");
ReactDOM.render(<App />, rootElement);

This useReducer(null,initState()) will later change and we will pass the reducer, instead of null as the first parameter.

The drawGrid() function loops through the grids row and column and build up the grid by creating a div for each cell. To style the cell the following class are assigned cell bg cell-border.

The required CSS for reference. We use flexbox for our layout.

.game {
display: flex;
flex-direction: column;
justify-content:center;
align-items: center;
}
.grid-container {
display: flex;
justify-content:center;
align-items: center;
}
.grid {
width:500px;
height:500px;
display: grid;
grid-template-columns: repeat(20, 1fr);
grid-template-rows: repeat(20, 1fr);
grid-gap: 0;
background: black;
}
.cell {
display: inline-block;
}
.cell-border{
border: 1px solid #e74c3c;
}

The code for step 2 is here https://codepen.io/rajeshpillai/pen/XwGREN?editors=0110

Design Tip: Feel free to refactor the drawGrid() function into <Rows><Row> and <Col> components as an exercise.

Step 3: Binding the Grid to the state object (render snake head, body, food)

Let us create come constants for tracking movement of snake. Currently, the snake can be controlled by the left, up, down, right or a,w,s,d keys.

// Lets create KEYS constant for tracking movement
const Keys = {
Space: 32,
Left: 37,
Up: 38,
Right: 39,
Down: 40,
a: 65, // left
w: 87, // up
s: 83, // down
d: 68 // right
}
// Default move the snake to the right
var move = Keys.Right;

Now, within the App function lets us create a helper function named “cellStyle” which return the style of the grid cells (css classes) according to state.

cellStyle = (cell) => {
const { snake, food, showGrid } = state;
let style = `cell `;
if (snake.head.row == cell.row && snake.head.col == cell.col) {
style= `cell head head-up`;
if (move == Keys.Left) {
style= `cell head head-left`;
} else if (move == Keys.Up) {
style= `cell head head-up`;
} else if (move == Keys.Right) {
style= `cell head head-right`;
} else if (move == Keys.Down) {
style= `cell head head-down`;
}
} else if (food.row == cell.row && food.col == cell.col) {
style= `cell food`;
} else if (snake.tail.find(t => t.row === cell.row
&& t.col === cell.col)) {
style = `cell tail`;
}

style = showGrid ? style + ' cell-border' : style;
return style;
}

The pseudocode for the above logic is given below.

  • Grab the snake, food, and showGrid from the state object.
  • Assign default class ‘cell’ to the style variable.
  • If the current cell.row and cell.col overlaps the snake.head.row and snake.head.col then set style the cell according to the movement of the snake.
  • Else if the cell.row and cell.col is the same as the food.row and food.col then style the food cell.
  • Otherwise, style the snake’s tail if it matches the condition specified in the if block.

Update the drawGrid() function as shown below.

// Generate grid based on row X col
drawGrid = () => {
const {grid} = state;
return (
grid.map((row, i) => {
return row.map(cell => {
let actorStyle = cellStyle(cell);
return <div key={cell.row+cell.col}
className={actorStyle} />
});
})
);
}

The pseudocode for the above logic is given below.

  • While looping through the grid cells and columns
    ++ get the style for the cell as per state
    ++ return a new <div> block and set the style

The final output will be as shown in the figure below. You can see the snake head which is a triangle and the food, ‘apple’ drawn on the grid.

The code for step 3 is here https://codepen.io/rajeshpillai/pen/XwGRwg?editors=0010

Step 4: Moving the snake when the game starts

Let us now learn how to move the snake. And we begin by coding the game loop. Every game has a game loop. The game loop is an infinite loop (well unless the game is ended) and it simply renders the UI according to state and actions.

Let us begin by implementing a custom, useTimeout function

useTimeout = (fn, timeout) => {
interval = setTimeout(fn.bind(null), timeout);
}

The useTimeout is a simple wrapper which stores the interval handle to a local function variable.

Now, let start with the state updater function using the useReducer hook.

const [state, dispatch] = useReducer(reducer,initState());

The default reducer is defined as below. A reducer takes a new state and an action. And according to the action updated state is returned back.

const reducer = (state, action) => {
switch (action.type) {
case 'game_lost':
return {
...state,
showGrid: state.showGrid,
lost: true,
message: 'Press <space> or touch/click to start the game',
inprogress: false, // Used in Timer
}
case 'update':
return {
...state,
...action.newstate
}

case 'toggle_grid':
return {
...state,
showGrid: !state.showGrid
};

case 'restart':
let newState = {
...state,
message: 'Game in progress ☝',
inprogress: true,
lost: false,
snake: {
...state.snake,
head: {
row: Math.floor(random() * 5),
col: Math.floor(random() * 5),
},
tail: [],
}
}
return newState;
default: {
return state;
}
}
};

Now, let’s begin a simple game loop to update the snakes x and y velocity (movement) and use the reducer to update the new state.

gameLoop = () => { 

// 💡 The snake x and y velocity variables.
/*
xv = 0 -> no movement on x-axis/horizontally
xv = 1 -> move right
xv = -1 -> move left

yv = 0 -> no movement on y-axis/vertically
yv = 1 -> move down
yv = -1 -> move up
*/

let xv = 1 , yv = 0;

if (move == Keys.Left){
xv = -1;
yv = 0;
} else if (move == Keys.Right){
xv = 1;
yv = 0;
} else if (move == Keys.Up){
xv = 0;
yv = -1;
} else if (move == Keys.Down){
xv = 0;
yv = 1;
}

// Initialize the nextState and dynamically modify it.
const nextState = {
snake: {
...state.snake,
head: {
row: state.snake.head.row + yv,
col: state.snake.head.col + xv
},
},
food: 1
};
    //Update the newstate by dispatching 'update' action
dispatch({
type: 'update',
newstate: nextState
});

// Clear the timeout and start the game loop again.
clearTimeout(interval);
useTimeout(gameLoop, 1000/6);
} // gameLoop end

The pseudocode for the above logic is given below.

  • Initialize the xv and yv variables
  • Based on the key pressed, reset the variables. i.e if left arrow is pressed then set xv = -1 and yv= 0. Setting xv to -1 means move to the left and yv to 0, to stop the vertical movement.
  • Computer the next state
  • Call the dispatch action passing in the action name and the new state parameter
  • Clear the timer
  • Restart the timer again.

The default value of the move variable is set to move right with the below line of code.

// Default move the snake to the right
var move = Keys.Right;

Now to run the loop (for our testing) we will add a useEffect hook and invoke the game loop.

useEffect(() => {
gameLoop();
},[]); // Passing an [] only executes this code once.

The full code for this step is here https://codepen.io/rajeshpillai/pen/pmYrbK?editors=0010

Step 5: Control the snake movement with keyboard

Now let us control the movement of the snake with the keyboard. For this, we will hook onto the keydown event handler of the browser document. We will set this handle in the useEffect hook.

NOTE: The useEffect hook is executed after the render cycle. It can return a function which is used to clean up the side effect of the hook. In this case removing the event handler.
useEffect(() => {
document.addEventListener('keydown', handleKey);
return function cleanup() {
document.removeEventListener('keydown', handleKey);
}
},[]);

Now let’s take a look at the handleKey function.

handleKey = (e) => {
const {snake} = state;
// Set the move variabls to the current key pressed
if (e.which == Keys.Left || e.which == Keys.a) { // left /a
move = Keys.Left;
}
else if (e.which == Keys.Right || e.which == Keys.d) {//right/d
move = Keys.Right;
}
else if (e.which == Keys.Up || e.which == Keys.w) { // up /w
move = Keys.Up;
}
else if (e.which == Keys.Down || e.which == Keys.s) { // down /s
move = Keys.Down;
}
}

In the handleKey function, we simply set the current move variable to the respective key constant. Since the game loop is already using the current state variables, the state changes because of key presses is automatically reflected in the game.

The code for this step is here https://codepen.io/rajeshpillai/pen/LoamLE?editors=0010

Step 6: Handle ‘space’ bar key to start the game.

Now we want to only start the game when the user clicks on the screen or press the space key. Let’s take a look at the code for this.

Create a useState for setting speed in the App function(Rest all code is same).

const [speed, setSpeed] = useState(6);  // 6 fps

Update the useEffect code as shown below.

useEffect(() => {
document.addEventListener('keydown', handleKey);
document.addEventListener('click', handleClick); // Add this
return function cleanup() {
document.removeEventListener('keydown', handleKey);
document.removeEventListener('click', handleClick);//Add this
}
},[]);

Let’s first take care of the handleClick event.

handleClick = (e) => {
if (e.target.className.indexOf('cell') > -1) {
restart();
}
}

We call an internal restart() method which we click on the document. We will take a look at the restart method shortly.

Let’s also take care of the ‘space’ bar key being pressed. Modify the handleKey method as shown below.

if (e.which == Keys.Space) {  // space
restart();
}
// REST of the code

Now, delete the gameLoop invocation that we are doing in the useEffect. Removing this will prevent the game to be running immediately on loaded.

useEffect(() => {
//gameLoop();
},[]);

Let us now take a look at the restart() function.

restart = () => {
const {inprogress} = state;
// If game in progress simply return from here.
if (inprogress) return;
dispatch({type:'restart'});
setSpeed(6);
useTimeout(gameLoop, fps());
}

Couple of things to note in the restart()

  • Grab the progress state
  • Dispatch an action to change the state
  • Set the default game speed
  • Trigger the gameloop
  • fps() is a helper method which returns the interval value dynamically.

Let’s take look at the fps() function

// Dyamically compute fps based on speed.
// speed increases as the snake eats the apple.
fps = ()=> {
return 1000 / speed;
}

The speed variable will change once the snake grab the food (We will see the code shortly for this).

The code for this step is here https://codepen.io/rajeshpillai/pen/LoamqZ

Step 7: End the Game when the snake hits the wall.

At this point, if you press the ‘space’ key or click on the game, the snake will start moving. But currently, it just seems going beyond the game walls. Let us now add the check to end the game if the snake touches the wall boundary.

// Returns true if the snake collides with the boundaries 
// otherwise false
didSnakeCollideWithWall = () => {
const {snake, grid } = state;
return (
snake.head.col >= grid.length ||
snake.head.row >= grid.length ||
snake.head.col < 0 ||
snake.head.row < 0
);
}

The didSnakeCollideWithWall() function grabs the current state and checks if the head of the snake touches the grid boundaries and return true/false accordingly.

Now update the gameLoop() and if the snake collides with the wall dispatch a message to stop the game.

const collideWithWall = didSnakeCollideWithWall();
if (collideWithWall) {
clearTimeout(interval);
dispatch({type:'game_lost'});
return;
}

Step 8: Eat the apple (food)

First, let’s fix the rendering of the apple. Currently, the apple not being rendered on the screen when the game starts.

To fix this open up the gameLoop () and fix the next state as shown by removing the food key (We will create this dynamically)

const nextState = {
snake: {
...state.snake,
head: {
row: state.snake.head.row + yv,
col: state.snake.head.col + xv
},
},
};

Now run the app, and you will see the apple stays on the screen. Now we have to detect the collision of the snake with the head. And if they collide we have to increment the game points and also make the snake grow.

First, let’s detect the collision of the snake with the apple. Open up the gameLoop function and add the following code.

const collideWithWall = didSnakeCollideWithWall();
const collideWithFood = didSnakeGotFood();

Let us now write the collision function.

// Returns true if the snake collides with apple otherwise false
didSnakeGotFood = () => {
const {food, snake } = state;
return food.row == snake.head.row &&
food.col == snake.head.col;
}

The above function checks whether the snake head and the food locations matches. If it matches, it means there is a collision and the function will return true, and otherwise, false.

Now we also have to update the nextState variable with the fact that collision happened. If the collision happened then increment the game score and also randomize the food to a new position.

const nextState = {
snake: {
...state.snake,
head: {
row: state.snake.head.row + yv,
col: state.snake.head.col + xv
}
},
food: collideWithFood ? randomizeFood() : state.food,
};

Let’s take a look at the randomizeFood() function.

// Position apple randomly on the grid.
// If the new position is overlapping the snake randomize again.
randomizeFood = () => {
const {snake} = state;
const newFood= {
row: Math.floor(Math.random() * 10),
col: Math.floor(Math.random() * 10),
}

// If newFood position is same as withing snake path, randomize
if (snake.head.row == newFood.row
&& snake.head.col == newFood.col) {
return randomizeFood();
}

return newFood;
}

The source code for this step is here https://codepen.io/rajeshpillai/pen/NVJzVW

Step 9: Increment the Score and display the score and Timer on the UI

To increment the scope update the nextState as shown below.

const nextState = {
snake: {
...state.snake,
head: {
row: state.snake.head.row + yv,
col: state.snake.head.col + xv
},
tail: [state.snake.head,...state.snake.tail]
},
food: collideWithFood ?
randomizeFood() : state.food,
score: collideWithFood ? score + 1 : score, // INCREMENT
};

To display the score make the following changes right above the .grid-container div. We are now also showing the timer.

<div className="info"> 
<h4 className="score">SCORE: {score} </h4>
<Timer pause={!inprogress} />
</div>

Now to actually increment the score we are taking the above approach. One is we can use a score variable and update it. But since we will be adding more cells to the snake in the next step when it grabs the food, we will use the score variable to display the score on the UI.

The source code for this step is here https://codepen.io/rajeshpillai/pen/OYqoWm?editors=0010

Step 10: Grow the snake (tail) and increase the game speed

So, now we have a score displayed. Let’s grow the snake once it grabs the food. The quickest way to achieve this is to add a tail in the execution of the game loop and then check if the snake didn’t grab the food, then pop the tail from the tail array.

So, the below code adds a tail to the end of the tail array.

tail: [state.snake.head,...state.snake.tail]

Then before updating the game state, do a quick check as shown.

// If the snake doesn't collide with food pop
// a cell from the tail
// Read Note at the start of the program. ☝
if (!collideWithFood) {
nextState.snake.tail.pop();
}
NOTE: The pop method removes the last element from an array and returns that value to the caller.

The code for this step is here https://codepen.io/rajeshpillai/pen/gJEdEX?editors=0010

Step 11: End the game if the snake crosses itself.

We want to end the game if the snake crosses itself. Add the below code in the top section of the gameLoop function.

const collideWithSelf = didSnakeCollideWithSelf();

We now have to implement the function didSnakeCollideWithSelf as shown below.

// Returns true if the snake collides with self otherwise false
didSnakeCollideWithSelf = () => {
const {head, tail } = state.snake;
return tail.find(t => t.row === head.row
&& t.col === head.col)
}

Now, update the game loop and terminate/end the game if the above condition is true and dispatch a game_lost message.

if (collideWithWall || collideWithSelf) {
clearTimeout(interval);
dispatch({type:'game_lost'});
return;
}

Show the game message on the screen by adding the code just above the drawGrid() method in the function return.

{ lost && 
<div className="game-lost">
You lost😢!
</div>
}

The code for the current step is here https://codepen.io/rajeshpillai/pen/XwGxpX?editors=0010

Step 12: Make it fast. Increase the game speed if the snake grabs the food.

First, update the code in the game loop as shown below.

if (!collideWithFood) {
nextState.snake.tail.pop();
} else {
increaseSpeed(speed);
}

Let’s implement the increaseSpeed function.

// Increases the speed.  The speed doesn't exceeds the value 16.
increaseSpeed = (s)=> {
s = Math.min(++s, 16);
setSpeed(s);
}

Now run the game and enjoy. Please modify the game and enhance it to your need and share your learnings.

The code for this step is here https://codepen.io/rajeshpillai/pen/NVJOwm?editors=0010

NOTE: This code was done to quickly test the hooks feature. Comments, suggestions, optimization tips are really appreciated.

History

  • 04-June-2019 — Article drafted for Patreons

Self Promotion

You can support my work on patreon for early access to tutorials/videos https://www.patreon.com/unlearninglabs
Or and subscribe to Twitter at https://twitter.com/rajeshpillai
Or and subscribe to https://www.youtube.com/user/tekacademylabs

Lowest Coupon code for my udemy course ( $9.99)
Code Your Own ReactJS using core javascript
ReactJS Master Class


ReactJS — Build a drunken snake game using hooks was originally published in codeburst on Medium, where people are continuing the conversation by highlighting and responding to this story.