DEV Community

Maksim Ivanov
Maksim Ivanov

Posted on • Originally published at maksimivanov.com on

Command Line Applications Using React - Snake Game Tutorial

In this tutorial we’ll learn how to build a CLI program using React and Javascript. We’ll be building a classic snake game using Ink library.

One of the most powerful features of React is that it supports different renderers. That means you aren’t limited with browser and DOM.

Most famous example is ReactNative, but there are other technologies as well. React is actively being used in game dev to build UI (Battlefield V UI, Minecraft launcher, e.t.c)

My mind was blown away though when I saw Ink - react renderer that outputs to console!

Create New Ink App

Let’s begin by bootstrapping our console application.

Create a new directory for your project. Open that directory and run create-ink-app:

mkdir snake-game
cd snake-game
npx create-ink-app
Enter fullscreen mode Exit fullscreen mode

(Optional) I prefer using spaces for indentation - so I open .editorconfig and switch indent_style to space

Display The Game Field

Ink provides a bunch of components to display. We’ll use Text and Box. Box is somewhat similar to div in HTML.

Define the field size:

const FIELD_SIZE = 16
const FIELD_ROW = [...new Array(FIELD_SIZE).keys()]
Enter fullscreen mode Exit fullscreen mode

Then we create an array that we’ll use to generate cells of our game field.

Change the return value of the App component to this:

<Box flexDirection="column" alignItems="center">
<Text>
  <Color green>Snake</Color> game
</Text>
<Box flexDirection="column">
  {FIELD_ROW.map(y => (
    <Box key={y}>
      {FIELD_ROW.map(x => (
        <Box key={x}> . </Box>
      ))}
    </Box>
  ))}
</Box>
</Box>
Enter fullscreen mode Exit fullscreen mode

By default Box components have display: flex. And you can also specify other flex attributes as their props.

You can run the game to see what it renders:

snake-game
Enter fullscreen mode Exit fullscreen mode

You should see this:

snake game field

Add Food And Snake

Time to add items to our game.

Define foodItem to hold current position of food. Add this to global scope:

let foodItem = {
x: Math.floor(Math.random() * FIELD_SIZE),
y: Math.floor(Math.random() * FIELD_SIZE),
}
Enter fullscreen mode Exit fullscreen mode

Define snakeSegments as a useState hook inside our App component to hold our snake position. It will be an array of snakes body segments.

const [snakeSegments, setSnakeSegments] = useState([
{ x: 8, y: 8 },
{ x: 8, y: 7 },
{ x: 8, y: 6 },
])
Enter fullscreen mode Exit fullscreen mode

Define getItem function with following content:

const getItem = (x, y, snakeSegments) => {
if (foodItem.x === x && foodItem.y === y) {
  return <Color red></Color>
}

for (const segment of snakeSegments) {
  if (segment.x === x && segment.y === y) {
    return <Color green>■</Color>
  }
}
}
Enter fullscreen mode Exit fullscreen mode

Now update the return value of our App to use getItem instead of rendering dots.

<Box flexDirection="column" alignItems="center">
<Text>
  <Color green>Snake</Color> game
</Text>
{intersectsWithItself ? (
  <EndScreen size={FIELD_SIZE} />
) : (
  <Box flexDirection="column">
    {FIELD_ROW.map(y => (
      <Box key={y}>
        {FIELD_ROW.map(x => (
          <Box key={x}> {getItem(x, y, snakeSegments) || "."} </Box>
        ))}
      </Box>
    ))}
  </Box>
)}
</Box>
Enter fullscreen mode Exit fullscreen mode

Now if there is food or snake segment in specific point - we render it instead of the dot.

After you run the game this time - you should see this:

snake game

Make Snake Move

Now we’ll need to add a game timer that will update the status of our game every 50ms so we can move our snake.

Using timers in React is not that straightforward and there is an article by Dan Abramov about that. We’ll use useInterval hook iplementation from it.

Create file useInterval.js with following content:

"use strict"
const { useEffect, useRef } = require("react")

module.exports = function useInterval(callback, delay) {
const savedCallback = useRef()

useEffect(() => {
  savedCallback.current = callback
}, [callback])

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

Create DIRECION constant to hold directions our snake can go:

const DIRECTION = {
RIGHT: { x: 1, y: 0 },
LEFT: { x: -1, y: 0 },
TOP: { x: 0, y: -1 },
BOTTOM: { x: 0, y: 1 }
};
Enter fullscreen mode Exit fullscreen mode

Create new variable direction using useState hook inside of our App component:

const [direction, setDirection] = useState(DIRECTION.LEFT)
Enter fullscreen mode Exit fullscreen mode

Create new function, called newSnakePosition

function newSnakePosition(segments, direction) {
const [head] = segments
return segments.map(segment => ({
  x: limitByField(segment.x + direction.x),
  y: limitByField(segment.y + direction.y),
}))
}
Enter fullscreen mode Exit fullscreen mode

newSnakePosition uses limitByField function to handle off-board positions of our snake. Implement this function:

const limitByField = x => {
if (x >= FIELD_SIZE) {
  return 0
}
if (x < 0) {
  return FIELD_SIZE - 1
}
return x
}
Enter fullscreen mode Exit fullscreen mode

Now we can use setInterval to call setSnakeSegments using newSnakePosition in our App component:

useInterval(() => {
setSnakeSegments(segments => newSnakePosition(segments, direction))
}, 50)
Enter fullscreen mode Exit fullscreen mode

At this point your game should look like this:

snake gif

Make Snake Move Properly

Now the snake is moving sideways. We need to update the newSnakePosition function to fix it.

Update the contents of newSnakePosition function to match the following:

function newSnakePosition(segments, direction) {
const [head] = segments
const newHead = {
  x: limitByField(head.x + direction.x),
  y: limitByField(head.y + direction.y),
}
return [newHead, ...segments.slice(0, -1)]
}
Enter fullscreen mode Exit fullscreen mode

Implement Eating And Growing

It’s time to implement eating and growing. To do this we’ll need to detect collision of snakes head with food.

Implement collidesWithFood function:

function collidesWithFood(head, foodItem) {
return foodItem.x === head.x && foodItem.y === head.y
}
Enter fullscreen mode Exit fullscreen mode

Here we check if foodItem and head of the snake have same position.

Now use it inside of the newSnakePosition function:

function newSnakePosition(segments, direction) {
const [head] = segments
const newHead = {
  x: limitByField(head.x + direction.x),
  y: limitByField(head.y + direction.y),
}
if (collidesWithFood(newHead, foodItem)) {
  foodItem = {
    x: Math.floor(Math.random() * FIELD_SIZE),
    y: Math.floor(Math.random() * FIELD_SIZE),
  }
  return [newHead, ...segments]
} else {
  return [newHead, ...segments.slice(0, -1)]
}
}
Enter fullscreen mode Exit fullscreen mode

Here we always return newHead position and then if we have collided with food - we teleport food to a new position.

Add End Of Game

At this point the game should be playable. But it’s impossible to loose. Let’s fix this.

Add this code before you use useInterval inside App component:

const [head, ...tail] = snakeSegments
const intersectsWithItself = tail.some(
segment => segment.x === head.x && segment.y === head.y
)
Enter fullscreen mode Exit fullscreen mode

We need to stop the game when snake bites itself. Add this ternary operator inside of useInterval call.

useInterval(
() => {
  setSnakeSegments(segments => newSnakePosition(segments, direction))
},
intersectsWithItself ? null : 50
)
Enter fullscreen mode Exit fullscreen mode

It will disable interval by setting timeout to null when snake intersects with itself.

Now add the end screen. Create new file EndScreen.js with following contents:

"use strict"

const React = require("react")
const { Color, Box } = require("ink")

module.exports = ({ size }) => (
<Box
  flexDirection="column"
  height={size}
  width={size}
  alignItems="center"
  justifyContent="center"
>
  <Color red>You died</Color>
</Box>
)
Enter fullscreen mode Exit fullscreen mode

Use importJsx to import EndScreen in ui.js:

const EndScreen = importJsx("./EndScreen")
Enter fullscreen mode Exit fullscreen mode

Update return value of the App component to match this:

<Box flexDirection="column" alignItems="center">
<Text>
  <Color green>Snake</Color> game
</Text>
{intersectsWithItself ? (
  <EndScreen size={FIELD_SIZE} />
) : (
  <Box flexDirection="column">
    {FIELD_ROW.map(y => (
      <Box key={y}>
        {FIELD_ROW.map(x => (
          <Box key={x}> {getItem(x, y, snakeSegments) || "."} </Box>
        ))}
      </Box>
    ))}
  </Box>
)}
</Box>
Enter fullscreen mode Exit fullscreen mode

Here you go - CLI React-based Snake game!

snake final

Conclusion

Even though Ink is not intended to make games - as you can see even this is totally possible.

I like how easy it is now to create cli apps of any level of complexity, yay!

Here is a repo with all the code. Ping me in telegram if you have any questions.

Top comments (0)