🐍Creating a Snake Game with NextJS and RxJS

Views:
Create a Snake game using NextJS and RxJS. React meets RxJS!

Inspired by taming snakes with reactive streams using solely HTML5 and JavaScript together with RxJS, today I will use NextJS along with RxJS to create a game called Next Snake. Without further ado, let's experience the real effect (not yet adapted to the mobile page).

Preliminary design

Firstly, I plan to bootstrap the project with create-next-app, which is the easiest way to get started with Next.js. Then, to use RxJS in the React world, I will use a library called observable-hooks to connect the observable world and the React world. Therefore, the ideal approach is for the heavy logic to take place behind the observable streaming, and using that streaming data to drive React rendering state.

Using RxJS in React

observable-hooks provides a variety of observable React hooks to connect RxJS and React. Its core concept is about two world: the Observable World and the Normal World.

These two worlds are just conceptual partition. The Observable World is where the observable pipelines are placed. It could be inside or outside of the React components. The Normal World is anyplace that does not belong to the Observable World. In my opinion, it should be the React state. Let's take an example:

javascript
Copy code
import * as React from "react"; import { useObservableState } from "observable-hooks"; import { timer } from "rxjs"; import { switchMap, mapTo, startWith } from "rxjs/operators"; // the streaming data in the RxJS world function transformTypingStatus(event$) { return event$.pipe( switchMap(() => timer(1000).pipe(mapTo(false), startWith(true))) ); } const App = () => { // using RxJS streaming state with React state way const [isTyping, updateIsTyping] = useObservableState( transformTypingStatus, false ); return ( <div> <input type="text" onKeyDown={updateIsTyping} /> <p>{isTyping ? "Good you are typing." : "Why stop typing?"}</p> </div> ); };
Good to know:
In order to better understand the use of RxJS in React, I'm going to list the APIs of observable-hooks used in the project. This will help you fully enjoy declarative and reactive programming with RxJS in React.

- useObservable: Accept a function that returns an Observable. Optionally accepts an array of dependencies which will be turned into Observable and be passed to the epic function. Using useObservable to create or transform Observables and avoid repeated operations in React functional components.
- useObservableState: Get values from Observables, the values can be used as states in React
- useSubscription: the achievement of RxJS subscribe in React.
- useObservableCallback: Returns a callback function and an events Observable. Whenever the callback is called, the Observable will emit the first argument of the callback. Can be treated as fromEvent in RxJS.
- useObservableRef: Returns a mutable ref object and a BehaviorSubject. Whenever ref.current is changed, the BehaviorSubject will emit the new value.

Code structure

Our entire logic takes place in the Snake component. This component is designed like this:

typescript
Copy code
'use client'; import { useState } from 'react'; import { Button } from '@/components/ui/button'; function Snake() { const [isStopped, setIsStopped] = useState(true); const [isGameOver, setIsGameOver] = useState(false); const [speed, setSpeed] = useState(SPEED); const scene = useScene({ isStopped, speed, onGameOver: () => { setIsGameOver(true); }, }); return ( <div className="flex items-center justify-center flex-col"> <div className=" flex items-center"> {/* buttons that handle speed change */} <Button>Speed + 10</Button> <Button>Speed - 10</Button> </div> <div> <div>{scene.score}</div> {isGameOver && (Game Over!)} {scene.apples.map((cell, index) => { ... })} {scene.snake.map((cell, index) => { ... })} </div> <Button>Start</Button> </div> ); } export default Snake;

Apart from the stop and gameOver states, the core game data is the scene state object, which contains the score, apples, and snake states that can be rendered at the template. What's more, useScene is not a normal React hook; inside it is a world of observable streams.

RxJS Streaming Implementation

Based on the requirements of the game, we learnt that we need the following features for our game.

  • Use arrow keys to steer the snake

  • Control the speed of movement of the snake

  • Record the player's score

  • Record the snake (including eating apples and moving)

  • Record apples (including generating new apples)

With the above functionality description let's design the RxJS flow we need.

Identifying the streams

direction$ stream

This stream is used to navigate the snake's movement. We need to listen to the keyboard arrow keys and map the key codes to our custom direction state.

typescript
Copy code
interface Directions { [key: string]: Direction; } const DIRECTIONS: Directions = { ArrowLeft: { x: -1, y: 0 }, // left ArrowRight: { x: 1, y: 0 }, //right ArrowUp: { x: 0, y: -1 }, // up ArrowDown: { x: 0, y: 1 }, // down }; // event$ is observable keyboard event const direction$ = event$.pipe( map((e) => DIRECTIONS[e.key]), filter((e) => !!e), scan((pre, cur) => nextDirection(pre, cur)), startWith(INITIAL_DIRECTION), distinctUntilChanged() )
Good to know:
The scan() operator works a lot like Array.reduce(), but instead of just giving you the final value, it outputs each step along the way. With scan(), you can keep adding up values and reduce a stream of events to a single value continuously. This helps us track the previous direction without needing any external state.

score$ stream

This stream is designed to be used as a broadcast, which can be piped to increase our snake's length. It starts with an initial score of 0.

typescript
Copy code
const score$ = new BehaviorSubject(0)

snake$ stream

This stream is more complicated since the snake changes with:

  1. Time changes. We use something like an interval timer to drive the snake's movement.

    typescript
    Copy code
    const timeTicker$ = interval(300)

    Consider that we need to control the snake's speed:

    typescript
    Copy code
    // speed$ is a simple speed stream const timeTicker$ = speed$.pipe( switchMap((speed) => interval(speed).pipe( map(([number]) => number) ) ) )
  2. Direction changes.

  3. Snake length. It can be increased every time the score$ stream emits a new score.

    typescript
    Copy code
    const SNAKE_LENGTH = 5 // score$ is a BehaviorSubject const snakeLength$ = score$.pipe( map((score) => score + SNAKE_LENGTH), share() )

Therefore, we can combine the snake$ like this:

typescript
Copy code
// The snake is an array of cells, each with x and y coordinates that change over time. const snake$ = ticker$ .pipe( withLatestFrom( direction$, snakeLength$, (_, direction, snakeLength) => [direction, snakeLength] as const ) ) .pipe( scan((acc, value) => snakeMove(acc, value[0], value[1]), generateSnake()), share() )

Good to know:
snake$ will be used as an input for apple$ and also serves as a source stream for the game scene. This means we would end up recreating that source stream with the second subscriptions to the same Observable. At this point, we can use share() operator.
typescript
Copy code
export const snakeMove = ( snake: Point2D[], direction: Direction, snakeLength: number ) => { const { x, y } = snake[0]; const dx = x + direction.x; const dy = y + direction.y; // If the snake's length increases, a new cell will be added to the start of the snake. if (snakeLength > snake.length) { const tail = { x: dx, y: dy }; snake.unshift(tail); } else { // Handle the movement of the snake over time // To understand, imagine the snake always "cuts" its tail and attaches it to its head, making it appear to move. const tail = snake.pop()!; tail.x = dx; tail.y = dy; snake.unshift(tail); } return [...snake]; };

apple$ stream

This stream mainly revolves around the snake. Each time the snake moves, we check if its head hits an apple. If it does, we remove the apple and place a new one randomly on the field. So, there's no need to create a separate stream for the apples.

typescript
Copy code
const apple$ = snake$.pipe( scan((apples, snake) => { if (apples.length === 0) { // init our apples return generateApples(snake); } else { // check if snake's head hits an apple // if it does, generate a new apple return eat(apples, snake); } }, [] as Point2D[]), distinctUntilChanged(), share() )

The final streams

Firstly, we will make some changes to our timeTicker$. Since we use interval to drive the movement of the snake, we can use requestAnimationFrame to allow the browser to change the position more smoothly. RxJS offers an animationFrame scheduler that can be used to create smooth browser animations. It ensures that scheduled tasks will happen just before the next browser content repaint, thus performing animations as efficiently as possible.

typescript
Copy code
import { animationFrameScheduler } from 'rxjs'; const timeTicker$ = speed$.pipe( switchMap((speed) => interval(speed, animationFrameScheduler).pipe( map(([number]) => number) ) ) )

Then, we prefer to stop the game whenever we do. So the final timeTicker$ should be like:

typescript
Copy code
const timeTicker$ = speed$.pipe( switchMap((speed) => interval(speed, animationFrameScheduler).pipe( withLatestFrom(isGameStoped$), filter(([, isStopped]) => { return !isStopped; }), map(([number]) => number) ) ) )

Finally , we combine snake , apple$ and score$ to generate and subscribe scene$

typescript
Copy code
const scene$ = useObservable(() => combineLatest([snake$, apples$, score$]) .pipe( map(([snake, apples, score]) => ({ snake, apples, score, })) ) .pipe(takeWhile((scene) => !isSceneGameOver(scene))) );

typescript
Copy code
useSubscription(scene$, undefined, undefined, onGameOver);

And each time when new apple appers, we increase our score

typescript
Copy code
const [scoreRef, score$] = useObservableRef(0); const appleEaten$ = useObservable(() => apples$.pipe(skip(1))); useSubscription(appleEaten$, () => { scoreRef.current += 1; });

You can check the whole RxJS code within React hooks: