DEV Community

Cover image for Build a Snake Game in functional JavaScript - Part 1
Patricio Ferraggi
Patricio Ferraggi

Posted on

Build a Snake Game in functional JavaScript - Part 1

If you are interested in reading this article in Spanish, check out my blog The Developer's Dungeon

Hey guys, I hope you are doing well and learning some new skills while quarantined. If that is the case then you are in luck because today we are gonna finish our beloved Snake Game written in functional JavaScript. If you haven't read the previous article you can do it here.

In our previous article, we end up having the UI ready and a small model of our snake. Today we are gonna extend that and complete the game, let us start by getting rid of the ugly parts.

Side Effects

Every software needs to produce side effects. If side effects would be avoided there would be no proof that the program actually runs. In our case, we have 2 types of side effects:

  • The output of the game (what you see on the screen)
  • The internal state of the game that needs to be updated (the position of the snake, apple, etc)

Pure functional programming languages come with certain tools that help us handle this in an elegant way. JavaScript, on the other hand, doesn't have these tools, they can be added by using libraries like Ramda Fantasy, but in our case, we are gonna use an approach called Functional Core Imperative Shell, which basically says that we can treat our code as mostly functional by keeping everything pure in one place and everything that is not pure near to the boundaries of our software, if you want to read more about it you can check the original blog post here

So following that approach I am gonna be very explicit about which parts of the game produce side effects and which ones don't.

The output of the game

This is our current 'UI' module

const r = require("ramda");
const { intercalate, update } = require("./helper");

const createWorld = (rows, columns, state) => {
  const repeatDot = r.repeat(".");

  const map = r.map(r.thunkify(repeatDot)(rows), repeatDot(columns));

  return r.pipe(addSnake(state), addApple(state))(map);
};

const addSnake = (state) => r.pipe(...r.map(update("X"), state.snake));

const addApple = (state) => update("O")(state.apple);

const displayWorld = (matrix) => {
  console.clear();
  console.log(intercalate("\r\n", r.map(intercalate(" "), matrix)));
};

const display = r.curry((rows, columns, state) => {
  return r.pipe(createWorld, displayWorld)(rows, columns, state);
});

module.exports = {
  display,
};
Enter fullscreen mode Exit fullscreen mode

if you check this code there is only one single place where we produce side effects and that is the 'displayWorld' procedure:

const displayWorld = (matrix) => {
  console.clear();
  console.log(intercalate("\r\n", r.map(intercalate(" "), matrix)));
};
Enter fullscreen mode Exit fullscreen mode

The rest of the code takes input and produces output, that's it.

The internal state of the game that needs to be updated

This is the index.js file where we start our game

const COLUMNS = 15;
const ROWS = 15;
const SPEED = 125;
let uglyMutableState = initialState;

const displayState = display(COLUMNS, ROWS);

const runGameLoop = () => {
  setInterval(() => {
    displayState(uglyMutableState);
  }, SPEED);
};

runGameLoop();
Enter fullscreen mode Exit fullscreen mode

As you can see here, we take the initial state of the game and then we have an interval that runs every few seconds and constantly displays the world of the game, in the future here we will have to call the logic to create a new state based on the previous one and update our uglyMutableState variable. We are gonna keep all the logic of the game pure and only modify this state variable from this file.

In a functional programming language, we would do this with Recursion but since JavaScript engines lack Tail Call Optimization doing this here would blow the stack almost immediately, we would have to use some dirty hacks like returning functions over functions to avoid this problem, but I thought at this point it was easier to be pragmatic and just follow the approach mentioned previously.

Getting input

Getting input is one of those things that is gonna modify our state, specifically the state that says where the snake should be moving.

// index.js
const setupInput = () => {
  readline.emitKeypressEvents(process.stdin);
  process.stdin.setRawMode(true);
  process.stdin.on("keypress", (str, key) => {
    if (key.ctrl && key.name === "c") process.exit();

    const options = {
      UP: addMove(direction.NORTH),
      LEFT: addMove(direction.WEST),
      DOWN: addMove(direction.SOUTH),
      RIGHT: addMove(direction.EAST),
    };

    const move = options[key.name.toUpperCase()];
    uglyMutableState = move(uglyMutableState);
  });
};

// snake.js
const direction = {
  NORTH: point(0, -1),
  SOUTH: point(0, 1),
  WEST: point(-1, 0),
  EAST: point(1, 0),
};

const initialState = {
  snake: [point(4, 3)],
  apple: point(5, 5),
  move: direction.EAST,
};

const addMove = r.curry((direction, state) =>
  isValidMove(direction, state.move) ? { ...state, move: direction } : state
);

// Checks that the snake always moves forward and
// cannot switch to the opposite direction
const isValidMove = (direction, move) =>
  direction.x + move.x !== 0 && direction.y + move.y !== 0;

Enter fullscreen mode Exit fullscreen mode

This function reads the key events and just adds a new direction to our mutable state, as you can see both addMove and isValidMove they don't mutate anything, addMove receives a state and produces a new one with the new direction of our snake, notice how we added a property called move to our initial state and how we modeled the directions using the point structure defined in the previous article.

The snake

Now we want to calculate the place where the snake is gonna be on every interval of our game loop AKA Moving the Snake. So let's do that:

const nextSnake = r.curry((cols, rows, state) => {
  return willCrash(cols, rows, state)
    ? initialState
    : {
        ...state,
        snake: willEat(nextHead(cols, rows, state), state.apple)
          ? [nextHead(cols, rows, state), ...state.snake]
          : [nextHead(cols, rows, state), ...r.dropLast(1, state.snake)],
      };
});
Enter fullscreen mode Exit fullscreen mode

Imagine that we already defined all the functions used here, let's go one by one, first, we ask if the snake is gonna crash to any part of its body, if it does then we return the initial state so the game starts again, if it doesn't crash then we return a new state. Inside the new state, we check again, is the snake gonna eat the apple? if yes then we move the snake and add one more point in its head so the snake grows. If, on the other hand, the snake does not eat the apple, then we add one point on the snake's head and we remove one from the back to give the impression that the snake is moving without growing. Now let's take a look at those missing functions:

const willEat = r.equals;
const willCrash = (cols, rows, state) =>
  r.find(r.equals(nextHead(cols, rows, state)))(state.snake);

const nextHead = (cols, rows, { move, snake }) =>
  point(
    modulo(cols)(r.head(snake).x + move.x),
    modulo(rows)(r.head(snake).y + move.y)
  );
Enter fullscreen mode Exit fullscreen mode

willEat just checks if to objects are equal, so we can just pass in the ramda.js equals using point-free notation.
nextHead is gonna take the head of the snake, and the current direction and just create one new point that is next to it. Here we use modulo so when the snake gets to one side of the map, it comes through the other.
willCrash checks if the new head of the snake is gonna match any point of the body.

The apple

Now that the snake is moving we can verify if the snake's head is gonna eat the apple and if that is the case we generate a new state where the apple is in a new random position.

const nextApple = r.curry((cols, rows, state) =>
  willEat(r.head(state.snake), state.apple)
    ? { ...state, apple: point(randomPos(cols), randomPos(rows)) }
    : state
);
Enter fullscreen mode Exit fullscreen mode

This is another case were technically we are not doing functional programming, as nextApple will produce different apples given the same input using the function randomPos.

Assembling our game logic

Now finally we have everything we need to assemble our game logic, how are we gonna do that? we are gonna create a function that receives the current state and calculates the new one based on the functions we just defined.

const step = r.curry((cols, rows, state) =>
  r.pipe(nextSnake(cols, rows), nextApple(cols, rows))(state)
);
Enter fullscreen mode Exit fullscreen mode

As you can see, first we create the snake, then we create the apple, and we returned the calculated state. Now we have to call this from our impure index.js

const COLUMNS = 15;
const ROWS = 15;
const SPEED = 125;
let uglyMutableState = initialState;

const setupInput = () => {
  readline.emitKeypressEvents(process.stdin);
  process.stdin.setRawMode(true);
  process.stdin.on("keypress", (str, key) => {
    if (key.ctrl && key.name === "c") process.exit();

    const options = {
      UP: addMove(direction.NORTH),
      LEFT: addMove(direction.WEST),
      DOWN: addMove(direction.SOUTH),
      RIGHT: addMove(direction.EAST),
    };

    const move = options[key.name.toUpperCase()];
    uglyMutableState = move(uglyMutableState);
  });
};

const displayState = display(COLUMNS, ROWS);
const nextState = step(COLUMNS, ROWS);

const runGameLoop = () => {
  setInterval(() => {
    displayState(uglyMutableState);
    uglyMutableState = nextState(uglyMutableState);
  }, SPEED);
};

setupInput();
runGameLoop();
Enter fullscreen mode Exit fullscreen mode

Now you can see what I meant about our game state being impure, every loop we get the new state and update our mutable state inside our index. Let's check the final result, shall we?

gamerunning

That is one good looking game, right? 😄

Conclusion

This example has a few caveats. It is clear that we could have gone more functional if we wanted to.

  • We could have incorporated Algebraic Data Types from ramda-fantasy.
  • Use functions everywhere by using r.merge instead of object destruction and r.ifElse instead of ternary operators
  • Use hacks to allow proper recursion instead of using 'setInterval'
  • Use Monads for IO

But I think that the whole point of doing JavaScript in a functional way is so that you don't feel the immediate pressure of doing everything as a language like Haskell would force you to so overall I think it is a good way of practicing functional programming on a language that is not strictly functional.

I really hope you enjoyed this small tutorial, it was very hard in the beginning but slowly I think I start to understand the basics of functional programming, I hope you do too. If you liked this article please share and let me know down below in the comments. If you have any doubts or you need some help don't doubt contacting me.

Top comments (0)