DEV Community

Mario
Mario

Posted on • Originally published at mariokandut.com on

Create the classic Snake game - React I

This article was originally published on mariokandut.com.

A while ago I was looking for a cable in the famous cable box , which I guess everybody has in his garage||closet||somewhere, and I have found an old Nokia phone. Remember the classic SNAKE game? Childhood Memories come alive. 😊 Let's build it in React with React Hooks, Typescript and a toolchain (Create-React-App).

This tutorial consists of two parts:

The final game will look like this:

React Snake

Table of Contents - Part I

  1. Create React app
  2. Add prettier (optional)
  3. Add Game Constants and Game Interval
  4. Game Logic

1. Create React App

Let's create a new React app with a toolchain. If you don't know what a toolchain is, please refer to the React docs here. I am going to use Create-React-App, have a look on github.

The name of the app will be react-snake and I want to use Typescript, so I have to add the typescript template flag.

Create a React app with this command in your terminal.

npx create-react-app react-snake --template typescript
Enter fullscreen mode Exit fullscreen mode

2. Add prettier (optional)

For basic code formatting, I'd like to add prettier. Prettier is an opinionated code formatter. Have a look at prettier.io. It does basically this:

Prettier takes your code and reprints it from scratch by taking the line length into account.

Install prettier as a dev dependency:

npm install --save-dev --save-exact prettier
Enter fullscreen mode Exit fullscreen mode

Create a config file for Prettier. It should be named .prettierrc and located in project root.

touch .prettierrc
Enter fullscreen mode Exit fullscreen mode

Then copy this into your config file to have a basic styling, more options avalaible here.

{
  "endOfLine": "lf",
  "semi": true,
  "singleQuote": true,
  "tabWidth": 2,
  "trailingComma": "all",
  "printWidth": 70
}
Enter fullscreen mode Exit fullscreen mode

3. Add Game Constants and Game Interval

Remove the demo code in App.tsx. It should look like this.

import React from 'react';
import './App.css';

function App() {
  return <div>Classic Snake Game</div>;
}

export default App;
Enter fullscreen mode Exit fullscreen mode

Create a file for constants - constants.tsx.

touch constants.tsx
Enter fullscreen mode Exit fullscreen mode

Copy/paste this starter code:

export const canvas_size = { x: 800, y: 800 };
export const snake_start = [{ x: 8, y: 7 }, { x: 8, y: 8 }];
export const direction_start = { x: 0, y: -1 };
export const apple_start = { x: 8, y: 3 };
export const scale = 40;
export const initial_speed = 200;
export const directions = {
  ArrowUp: { x: 0, y: -1 },
  ArrowDown: { x: 0, y: 1 },
  ArrowLeft: { x: -1, y: 0 },
  ArrowRight: { x: 1, y: 0 },
};
Enter fullscreen mode Exit fullscreen mode

Every game needs a game loop. It's the central part of the game engine and is responsible for balancing the logic of the game and the drawing on the canvas.

In good-old Vanilla JavaScript, we would use the setInterval method to do this. Unfortunately, with React hooks this is not possible. Read here why setInterval needs to be declarative Dan Abramov.

Hence, we have to create a useInterval React Hook.

touch useInterval.tsx
Enter fullscreen mode Exit fullscreen mode

Copy/paste this code:

// Custom Hook by Dan Abramov, https://overreacted.io/making-setinterval-declarative-with-react-hooks/
// TypeScript Added by Mario Kandut

import { useEffect, useRef } from 'react';

export function useInterval(callback: any, delay: number | null) {
  const savedCallback = useRef<() => void | null>();

  // Remember the latest callback.
  useEffect(() => {
    savedCallback.current = callback;
  }, [callback]);

  // Set up the interval.
  useEffect(() => {
    function tick() {
      if (typeof savedCallback?.current !== 'undefined') {
        savedCallback.current();
      }
    }
    if (delay !== null) {
      let id = setInterval(tick, delay);
      return () => clearInterval(id);
    }
  }, [delay]);
}
Enter fullscreen mode Exit fullscreen mode

That's now a great starting point.

4. Game Logic

We import the React hooks we want to use, useState, useEffect, useRef and the custom hook useInterval, as well as our game constants constants into App.tsx.

import React, { useState, useEffect, useRef } from 'react';
import { useInterval } from './useInterval';
import {
  canvas_size,
  apple_start,
  directions,
  scale,
  snake_start,
  initial_speed,
  direction_start,
} from './constants';
import './App.css';
Enter fullscreen mode Exit fullscreen mode

The movement of the snake on the canvas should be controllable with the arrow keys. To be able to do this, we have to listen to keyboard events and assign a role="button" to this div.

We can create now the game canvas in App.tsx.

<div className="wrapper">
  <div>Classic Snake Game</div>
  <div
    className="controls"
    role="button"
    onKeyDown={(event: React.KeyboardEvent) => moveSnake(event)}
  >
    <canvas
      style={{ border: '1px solid black' }}
      ref={canvasRef}
      width={canvas_size.x}
      height={canvas_size.y}
    />
    <button onClick={startGame}>Start Game</button>
  </div>
</div>
Enter fullscreen mode Exit fullscreen mode

We create the missing canvasRef:

const canvasRef = useRef<HTMLCanvasElement>(null);
Enter fullscreen mode Exit fullscreen mode

Now we create the empty moveSnake and startGame function, so we don't get errors or warnings.

const moveSnake = (event: React.KeyboardEvent) => {};
const startGame = () => {};
Enter fullscreen mode Exit fullscreen mode

We can start to show something on the screen - the snake and the apple.

useEffect(() => {
  const context = canvasRef.current?.getContext('2d');
  if (context == null) throw new Error('Could not get context');
  context.setTransform(scale, 0, 0, scale, 0, 0);
  context.clearRect(0, 0, canvas_size.x, canvas_size.y);
  // Draw Snake
  context.fillStyle = 'green';
  snake.forEach(({ x, y }) => context.fillRect(x, y, 1, 1));
  // Draw Apple
  context.fillStyle = 'red';
  context.fillRect(apple.x, apple.y, 1, 1);
}, [snake, apple]);
Enter fullscreen mode Exit fullscreen mode

The if(context === null) is necessary, since getContext can return null, refer to MDN.

So basically, we scale the 2D-context and draw the canvas. Then we draw green rectangles for the snake and red rectangles for the apple.

Game Controls

Now we can start interacting with the snake. Let's implement the function for the game controls.

First, we have to add an interface for coordinates:

export interface ICoords {
  x: number;
  y: number;
}
Enter fullscreen mode Exit fullscreen mode

Add the useState for directions:

const [direction, setDirection] = useState<ICoords>(direction_start);
Enter fullscreen mode Exit fullscreen mode

Update the moveSnake function.

const moveSnake = (event: React.KeyboardEvent) => {
  const { key } = event;
  // only if arrow key move
  if (
    key === 'ArrowUp' ||
    key === 'ArrowDown' ||
    key === 'ArrowRight' ||
    key === 'ArrowLeft'
  ) {
    setDirection(directions[key]);
  }
};
Enter fullscreen mode Exit fullscreen mode

Game Loop

Let's create the game loop:

const gameLoop = () => {
  const snakeCopy = [...snake]; // Create shallow copy to avoid mutating array
  const newSnakeHead: ICoords = {
    x: snakeCopy[0].x + direction.x,
    y: snakeCopy[0].y + direction.y,
  };
  snakeCopy.unshift(newSnakeHead);
  setSnake(snakeCopy);
};
Enter fullscreen mode Exit fullscreen mode

To avoid errors and warning for now, we need to create to empty functions:

const checkCollision = (
  piece: ICoords,
  snoko: ICoords[] = snake,
) => {};
const checkAppleCollision = (newSnake: ICoords[]) => {};
Enter fullscreen mode Exit fullscreen mode

With the imported useInterval hook, we can trigger the game loop. Put the following line before the return statement.

useInterval(() => gameLoop(), speed);
Enter fullscreen mode Exit fullscreen mode

Right now, nothing will move, because speed is NULL. Try to manually change speed to 1000 and see if the snake moving.

Now we can add the startGame function, and the missing states for the snake, apple, speed and gameOver.

const [snake, setSnake] = useState<Array<ICoords>>(snake_start);
const [apple, setApple] = useState<ICoords>(apple_start);
const [speed, setSpeed] = useState<number | null>(null);

const startGame = () => {
  setSnake(snake_start);
  setApple(apple_start);
  setDirection(direction_start);
  setSpeed(initial_speed);
};
Enter fullscreen mode Exit fullscreen mode

Since we have a startGame function, we should have an endGame function as well:

const endGame = () => {
  setSpeed(null);
};
Enter fullscreen mode Exit fullscreen mode

Collision Detection

The collision detection needs to handle three use cases:

  • snake collides with border of the canvas
  • snake collides with itself
  • snake collides with the apple

First we handle if the collision is with the border. The coordinates for the canvas border we have canvas_size.

const checkCollision = (piece: ICoords, snoko: ICoords[] = snake) => {
  // Wall Collision Detection
  if (
    piece.x * scale >= canvas_size.x ||
    piece.x < 0 ||
    piece.y * scale >= canvas_size.y ||
    piece.y < 0
  ) {
    return true;
  }

  return false;
};
Enter fullscreen mode Exit fullscreen mode

Now we have to detect if the snake collides with itself. Therefore, we check if the coordinates of a segment of the snake, and the current piece are the same.

Hence, we add this into checkCollision:

// Snake Collision Detection
for (const segment of snoko) {
  if (piece.x === segment.x && piece.y === segment.y) return true;
}
Enter fullscreen mode Exit fullscreen mode

Then we need to add the condition for triggering the collision detection. Add this line in the gameLoop after unshifting the snakeHead.

if (checkCollision(newSnakeHead)) endGame();
Enter fullscreen mode Exit fullscreen mode

We have to detect now if the snake collides with an apple and create a random apple. Let's make the checkAppleCollission funciton:

const checkAppleCollision = (newSnake: ICoords[]) => {
  if (newSnake[0].x === apple.x && newSnake[0].y === apple.y) {
    let newApple = createRandomApple();
    while (checkCollision(newApple, newSnake)) {
      newApple = createRandomApple();
    }
    setApple(newApple);
    return true;
  }
  return false;
};
Enter fullscreen mode Exit fullscreen mode

Create the random apple:

const createRandomApple = () => {
  return {
    x: Math.floor((Math.random() * canvas_size.x - 10) / scale),
    y: Math.floor((Math.random() * canvas_size.y - 10) / scale),
  };
};
Enter fullscreen mode Exit fullscreen mode

Add the checkAppleCollission to the gameloop with inserting this line after the if forcheckCollission:

if (!checkAppleCollision(snakeCopy)) snakeCopy.pop();
Enter fullscreen mode Exit fullscreen mode

Now some basic styling in App.css:

.wrapper {
  display: flex;
  justify-content: center;
  align-items: center;
  flex-direction: column;
}

.controls {
  display: flex;
  justify-content: center;
  align-items: center;
  flex-direction: column;
}
Enter fullscreen mode Exit fullscreen mode

Click the start button and try to see if it's working.

The current state of your React-Snake should look like this:

React Snake - Tutorial 1 End State

Continue with Styling - Part II or directly on dev.to Styling - Part II

Top comments (0)