The original link: blog. Thoughtram. IO/RXJS / 2017/0…

This article is translated by RxJS Chinese community, if you need to reprint, please indicate the source, thank you for your cooperation!

If you would like to translate more quality RxJS articles with us, please click here.

As we all know, the Web is evolving fast. Today, responsive programming is one of the hottest topics in Web development, along with frameworks like Angular or React. Responsive programming is becoming increasingly popular, especially in today’s JavaScript world. The community has changed dramatically from the imperative to the responsive programming paradigm. However, many developers still struggle, often deterred by the complexity of responsive programming (lots of apis), the shift in thinking (from imperative to responsive), and the sheer number of concepts.

Easier said than done, once people have acquired a skill they need to survive, they ask themselves how can I survive without that skill? (Translator’s Note: People don’t want to go out of their comfort zone.)

This article is not an introduction to reactive programming, but if you don’t know anything about reactive programming, I recommend the following resources:

  • Introduction to responsive programming – Andre Staltz
  • RxJS 5: Think in responsive ways – Ben Lesh
  • RxJS 5 Fundamentals – Chris Noring
  • Learn RxJS – Brian Troncone
  • Examples from the RxJS 5 operator – Brian Troncone

The purpose of this article is to learn how to use responsive thinking to build a well-known classic video game – Snake. Yes, you know the one! The game is fun, but the system itself is not simple, saving a lot of external state, such as scores, timers, or player coordinates. For the version we’ll implement, we’ll heavily use Observables and operators to avoid using external states altogether. Sometimes it can be simple and easy to store state outside the Observable pipe, but remember that we want to embrace responsive programming, and we don’t want to rely on any external variables to store state.

Note: We only use HTML5, JavaScript, and RxJS to transform programmatic-event-loop applications into reactive event-driven applications.

The code is available via Github, as well as an online demo. I encourage you to clone the project, do it yourself and implement some really cool game features. If you do, don’t forget to @me on Twitter.

directory

  • The game is overview
  • Set up a play area
  • Determining source flow
  • The snake’s turn to
    • Flow direction $
  • Record length
    • BehaviorSubject to save
    • Realize score $
  • Tame snake $
  • Generate the apple
    • Broadcast events
  • Integration of the code
    • Performance maintenance
    • Render the scene
  • The follow-up work
  • Special thanks to

The game is overview

As mentioned earlier, we’re reinventing snake, a classic video game from the late 1970s. We didn’t exactly copy the classic, we added some minor changes. Here’s how the game works.

The hungry snake is controlled by the player and the goal is to eat as many apples as possible. The apple will appear at random on the screen. Each time a snake eats an apple, its tail gets longer. The perimeter will not stop the snake! But remember, avoid a head-to-tail collision at all costs. Once they do, the game is over. How long can you survive?

Here’s a preview of the game in action:

For a concrete implementation, the line of blue squares represents the snake, and the snake head is black. Can you guess what an apple looks like? That’s right, the quickred cube. Everything here is made of squares, not because squares are beautiful, but because their shapes are simple enough to draw. The graphics weren’t good enough, but our intention was to transform imperative programming into responsive programming, not the art of the game.

Set up a play area

Before we can start implementing game features, we need to create the < Canvas > element, which allows us to use a powerful drawing API in JavaScript. We will use canvas to draw our graphics, including the game area, snakes, apples, and everything else needed for the game. In other words, the entire game is rendered in the

element.

If you don’t know anything about Canvas, check out Keith Peters’ class at Egghead.

Index. HTML is fairly simple, because basically all the work is done by JavaScript.

<html>
<head>
  <meta charset="utf-8">
  <title>Reactive Snake</title>
</head>
<body>
  <script src="/main.bundle.js"></script>
</body>
</html>
Copy the code

The script added to the end of the body is built output and contains all of our code. However, you may be wondering why there is no

element in . This is because we will be using JavaScript to create the elements. In addition, we define constants such as the number of rows and columns in the game area, and the width and height of the Canvas element.

export const COLS = 30;
export const ROWS = 30;
export const GAP_SIZE = 1;
export const CELL_SIZE = 10;
export const CANVAS_WIDTH = COLS * (CELL_SIZE + GAP_SIZE);
export const CANVAS_HEIGHT = ROWS * (CELL_SIZE + GAP_SIZE);

export function createCanvasElement() {
  const canvas = document.createElement('canvas');
  canvas.width = CANVAS_WIDTH;
  canvas.height = CANVAS_HEIGHT;
  return canvas;
}
Copy the code

We dynamically create a

element and append it to by calling the createCanvasElement function:

let canvas = createCanvasElement();
let ctx = canvas.getContext('2d');
document.body.appendChild(canvas);
Copy the code

Note that we get a reference to CanvasRenderingContext2D by calling the getContext(‘2d’) method of the

element. It is the CANVAS’s 2D rendering context, with which you can draw rectangles, text, lines, paths, and so on.

Ready! Let’s start writing the core mechanics of the game.

Determining source flow

According to the preview and description of the game, we know that our game needs the following features:

  • Use the arrow keys to control the snake
  • Keep track of the player’s score
  • Record the snake (including eating apples and moving)
  • Recording apples (including generating new ones)

In reactive programming, programming is all about data flow and input data flow. Conceptually, when reactive programming is performed, it establishes an observable pipeline that can act on changes. For example, users can interact with the app by pressing a button or simply starting a timer. So it’s all about figuring out what can change. These changes usually define the source stream. The key, then, is to identify the main sources that represent change, and then combine them to calculate everything you need, such as game state.

Let’s try to identify these sources through the function description above.

First, user input must change over time. The player uses the d-pad to control the snake. This means we have found our first source stream keyDown $, which emits a value every time a key is pressed.

Next, we need to keep track of the player’s score. The score depends on how many apples the snake eats. We could say that the score depends on the length of the snake, because every time the snake gets longer after eating an apple, we’re going to add one to the score. So, our next source stream is snakeLength$.

In addition, it’s important to find out in order to calculate any major data sources (such as scores) you need. In most scenarios, the source stream is merged into a more specific data stream. We’ll get to it soon enough. Now, let’s continue to find the main source stream.

So far, we have user input and scores. The rest are game-related or interaction-related streams, such as snakes or apples.

Let’s start with snakes. The snake’s core mechanic is simple, it moves over time, and the more apples it eats, the longer it gets. But what exactly is the snake’s source? For now, let’s put aside the apple eating and body length factor for a moment, because the snake moves over time, so it is most dependent on the time factor, for example, moving 5 pixels every 200ms. Thus, the source stream of the snake is a timer that generates a value at regular intervals, which we call ticks$. The flow also determines how fast the snake moves.

The last source is apple. When everything else is ready, apple is pretty simple. This flow is basically snake dependent. Every time the snake moves, we check to see if its head hits the apple. If they collide, they remove the apple and create a new apple in a random location. That said, we don’t need to introduce a new source stream to Apple.

Yes, the source has been traced. Here is a brief overview of all the source streams needed for the game:

  • keydown$: KeyDown event (KeyboardEvent)
  • snakeLength$: indicates the length of the snake (Number)
  • ticks$: timer, indicating the snake speed (Number)

These streams form the basis of the game, and other values we need, including scores, snakes, and apples, can be calculated from these streams.

In the next section, we’ll show you how to implement each source stream and combine them to produce the data we need.

The snake’s turn to

Let’s dive into the coding and implement the snake’s steering mechanism. As mentioned in the previous section, the snake’s steering relies on keyboard input. It’s actually quite simple to start by creating an Observable for keyboard events. We can do this using the fromEvent() operator:

let keydown$ = Observable.fromEvent(document.'keydown');
Copy the code

This is our first source stream, which emits a KeyboardEvent every time the user presses a key. Note that each keyDown event is literally emitted. However, what we really care about is just the D-pad, not all buttons. Before we tackle this specific problem, we define a constant mapping of arrow keys:

export interface Point2D {
  x: number;
  y: number;
}

export interface Directions {
  [key: number]: Point2D;
}

export const DIRECTIONS: Directions = {
  37: { x: - 1, y: 0 }, / / left?
  39: { x: 1, y: 0 },  / / right
  38: { x: 0, y: - 1 }, / / the key
  40: { x: 0, y: 1 }   / / the key
};
Copy the code

Each key in the KeyboardEvent object corresponds to a unique keyCode. To get the d-key code, we can look at this table.

Each direction is of type Point2D, which is just a simple object with x and Y attributes. Each attribute has a value of 1, -1, or 0, and the value indicates the direction in which the snake is moving. Later, we will use this orientation to calculate the new grid position for the snake’s head and tail.

Flow direction $

Now that we have the stream of keyDown events, each time the player clicks a key, we need to map it to a value, the KeyboardEvent to one of the above direction vectors. We can do this using the map() operator.

let direction$ = keydown$
  .map((event: KeyboardEvent) = > DIRECTIONS[event.keyCode])
Copy the code

As mentioned earlier, we receive each keystroke event because we haven’t filtered out keys we don’t care about, such as character keys. However, one could argue that we already filter by looking for events in the direction map. KeyCode not found in the map returns undefined. However, this is not really filtering for our stream, which is why we use the filter() operator to filter out the arrow keys.

let direction$ = keydown$
  .map((event: KeyboardEvent) = > DIRECTIONS[event.keyCode])
  .filter(direction= >!!!!! direction)Copy the code

Well, that’s easy too. The code above is good enough and works as expected. But there is room for improvement. Can you think of anything?

One thing we want to stop the snake from going in the opposite direction, for example, from left to right or from top to bottom. Behavior like this makes absolutely no sense, because the first rule of the game is to avoid head-to-tail collisions, remember?

The solution also wants to be simple. We cache the previous direction, and when a new KeyDown event is emitted, we check to see if the new direction is the opposite of the previous direction. Here is the function to compute the next direction:

export function nextDirection(previous, next) {
  let isOpposite = (previous: Point2D, next: Point2D) = > {
    return next.x === previous.x * - 1 || next.y === previous.y * - 1;
  };

  if (isOpposite(previous, next)) {
    return previous;
  }

  return next;
}
Copy the code

This is our first attempt to store state outside the Observable pipe because we need to store the previous direction, right? Using external state variables to hold the previous direction is indeed a simple solution. But wait! We’re trying to avoid that, aren’t we?

To avoid using external states, we need a way to aggregate infinite Observables. RxJS provides a handy operator to solve such problems: scan().

The scan() operator is very similar to array.reduce (), except that instead of returning the final aggregate value, it emits the generated intermediate value every time an Observable emits a value. With Scan (), we can aggregate values and merge the incoming stream of events into a single value an infinite number of times. This way, we can preserve the previous direction without relying on external states.

Here is the final direction$stream after scan() is applied:

let direction$ = keydown$
  .map((event: KeyboardEvent) = > DIRECTIONS[event.keyCode])
  .filter(direction= >!!!!! direction) .scan(nextDirection) .startWith(INITIAL_DIRECTION) .distinctUntilChanged();Copy the code

Note that we use startWith(), which emits an initial value when the source Observable (keydown$) starts emitting. Without startWith(), our Observable starts emitting values only when the player presses the button.

The second improvement is that it is emitted only if it is emitted in a different direction from the previous one. In other words, we just want different values. You may have noticed distinctUntilChanged() in the code above. This operator does the heavy lifting of suppressing duplicates for us. Note that distinctUntilChanged() will only filter out identical values between sends.

The following figure shows the direction$stream and how it works. The blue values represent the initial values, the yellow values represent values modified by the Observable pipe, and the orange values represent emitted values on the result stream.

Record length

Before implementing the snake itself, let’s think about how to record its length. Why do we need length in the first place? We need length information as a source of score data. In the world of imperative programming, every time the snake moves, we simply check for collisions and increase the score if there are. So there’s no record length at all. However, this still introduces another external state variable, which we want to avoid.

In the world of responsive programming, implementation is different. An easier way to do this is to use the snake$stream, which tells us whether the snake is growing or not every time it emits a value. However, it depends on the implementation of the Snake $stream, but that’s not how we’re going to implement it. From the beginning we know that Snake $relies on ticks$because it moves over time. The Snake $stream itself also accumulates into an array of bodies, and since it is based on ticks$, ticks$emits a value every x milliseconds. That is, the Snake $stream will generate a different value even if there is no snake collision. This is because the snake is constantly moving, so the array is never the same.

This can be difficult to understand because there are some peer dependencies between the different flows. For example, apples$depends on Snake $. The reason for this is that every time the snake moves, we need the array of snake bodies to check for collisions with the apple. However, the apples$stream itself would also accumulate an array of apples, and we needed a mechanism to simulate collisions while avoiding cyclic dependencies.

BehaviorSubject to save

The solution is to use BehaviorSubject to implement the broadcast mechanism. RxJS provides different types of Subjects with different functions. The Subject class itself provides the basis for creating more specialized Subjects. In summary, the Subject type implements both the Observer and Observable types. Observables define data flows and produce data, while Observers can subscribe to Observables and receive data.

A BehaviorSubject is a special type of Subject that represents a value that changes over time. Now, when an observer subscrires to a BehaviorSubject, it receives the last emitted value and all subsequent emitted values. It is unique in that it requires an initial value, so all observers receive at least one value when they subscribe.

Let’s go ahead and create a new BehaviorSubject using the initial value SNAKE_LENGTH:

// SNAKE_LENGTH specifies the initial length of the snake
let length$ = new BehaviorSubject<number>(SNAKE_LENGTH);
Copy the code

Here, it’s just a small step from implementing snakeLength$:

let snakeLength$ = length$
  .scan((step, snakeLength) = > snakeLength + step)
  .share();
Copy the code

In the code above, we can see that snakeLength$is based on length$, which is our BehaviorSubject. This means that whenever we use next() to provide a value to the Subject, the value will be emitted on snakeLength$. In addition, we used Scan () to accumulate length over time. Cool, but you might be wondering what this share() does, right?

As mentioned earlier, snakeLength$will later serve as the input stream for Snake $, but also as the source stream for the player’s score. Therefore, we subscribe to the same Observable a second time, resulting in the source stream being recreated. This is because Length $is a cold Observable.

If you don’t know anything about Hot and Cold Observables, we wrote about Cold vs Hot Observables earlier.

The key is to use share() to allow multiple subscriptions to observables that would otherwise recreate the source Observable. This operator automatically creates a Subject between the original Observable and all future subscribers. As soon as the number of subscribers goes from zero to one, it connects the Subject to the underlying source Observable and broadcasts all notifications. All future subscribers will connect to the middle Subject, so the underlying cold Observable actually has only one subscription.

Cool! Now that we have a mechanism for broadcasting values to multiple subscribers, we can proceed to implement score$.

Realize score $

Player scores are simple. Now, with snakeLength$, we can create a score$stream simply by using Scan () to accumulate player scores:

let score$ = snakeLength$
  .startWith(0)
  .scan((score, _) = > score + POINTS_PER_APPLE);
Copy the code

We basically use snakeLength$or length$to notify subscribers that there is a collision (if there is one), and we augment the score with POINTS_PER_APPLE, which is fixed per apple. Note that startWith(0) must precede scan() to avoid specifying seed values (initial cumulative values).

Take a look at the visualization we just implemented:

From the above figure, you might be wondering why the initial BehaviorSubject value only appears in snakeLength$and not in Score $. That’s because the first subscriber will cause share() to subscribe to the underlying data source, and the underlying data source will immediately issue a value that already exists when subsequent subscriptions occur.

Cool. Now that we’re ready, we’re going to implement snake flow, aren’t we excited?

Tame snake $

So far, we’ve learned a few operators that we can use to implement the Snake $stream. As discussed at the beginning of this article, we need something like a timer to keep hungry snakes moving. It turns out that there is a convenience operator called interval(x) that does this, emitting a value every x milliseconds. We call each value a tick.

let ticks$ = Observable.interval(SPEED);
Copy the code

We still have a little way to go from Ticks $to the final Snake $. Each time the timer is triggered, whether we want the snake to continue or increase its length depends on whether the snake ate the apple. So, we can still use the familiar scan() operator to accumulate an array of snake bodies. But, as you might have guessed, we still have a problem. How to import a direction$or snakeLength$stream?

That’s an absolutely legitimate question. If you want to easily access both the direction and the length of the snake in the Snake $stream, use variables outside the Observable pipe to store this information. However, we would again be violating the rule for changing the external state.

Fortunately, RxJS provides another very handy operator withLatestFrom(). This operator is used to combine streams, and it is exactly what we need. This operator applies to the main source Observable, which controls the proper sending of data to the result stream. In other words, you can think of withLatestFrom() as a way of limiting the output of secondary streams.

Now we have the tools we need to implement the final Snake $stream:

let snake$ = ticks$
  .withLatestFrom(direction$, snakeLength$, (_, direction, snakeLength) = > [direction, snakeLength])
  .scan(move, generateSnake())
  .share();
Copy the code

Our main source Observable is ticks$. Whenever a new value is emitted on the pipe, we take the latest value of direction$and snakeLength$. Note that even if the secondary stream emits values frequently (for example, if the player hits his head on the keyboard), the data will only be processed each time the timer emits a value.

In addition, we pass in the selector function withLatestFrom, which is called only when the main stream produces a value. This function is optional; if not passed, a list of all elements will be generated.

We don’t cover the move() function here, because the primary purpose of this article is to help you shift your thinking. However, you can find the source code for this function on GitHub.

The following image is a visual representation of the code above:

See how to throttle direction$? The key is withLatestFrom(), which is useful when you want to combine multiple streams and are not interested in the data emitted by the combined streams.

Generate the apple

As you may have noticed, as we learned more and more operators, it became easier to implement the core code blocks of our games. If you’ve made it this far, the rest is pretty easy.

So far, we’ve implemented streams like Direction $, snakeLength$, Score $, and Snake $. Now, if we put these flows together, we can actually manipulate snakes to run around. But if snake doesn’t have anything to eat, then the game isn’t fun. It’s boring.

Let’s generate some apples to satisfy the snake’s appetite. First, we need to sort out the states we need to preserve. It can be an object or an array of objects. Our implementation here will use the latter, apple’s array. Do you hear the bells of victory?

Well, we can use scan() again to accumulate the array of apples. We start by providing the initial value of the apple array, and then check for collisions every time the snake moves. If there’s a collision, we generate a new apple and return a new array. That way we can use distinctUntilChanged() to filter out exactly the same values.

let apples$ = snake$
  .scan(eat, generateApples())
  .distinctUntilChanged()
  .share();
Copy the code

Cool! This means that every time apples$produces a new value, we can assume that the snake ate an apple. All that is left to do is increase the score and notify other streams of the event, such as Snake $, which gets the latest value from snakeLength$to determine whether the snake’s body is longer.

Broadcast events

We already implemented the broadcast mechanism, remember? We use it to trigger the target action. Here is the code for eat() :

export function eat(apples: Array<Point2D>, snake) {
  let head = snake[0];

  for (let i = 0; i < apples.length; i++) {
    if (checkCollision(apples[i], head)) {
      apples.splice(i, 1);
      // length$.next(POINTS_PER_APPLE);
      return[...apples, getRandomPosition(snake)]; }}return apples;
}
Copy the code

The easy solution is to call length$.next(POINTS_PER_APPLE) directly in if. The problem with doing so is that we cannot extract the tool method into its own module (the ES2015 module). ES2015 modules are generally one file per module. The main purpose of organizing your code this way is to make it easier to maintain and derive.

A slightly more complicated solution is to introduce another stream, which we’ll call applesEaten$. This stream is based on apples$, and every time the stream emits a new value, we perform an action that calls length$.next(). To do this, we can use the do() operator, which executes a piece of code each time a value is emitted.

That sounds feasible. However, we need some way to skip the first value (the initial value) that Apple $emits. Otherwise, you end up with an opening shot that immediately adds to the score, which doesn’t make sense at the beginning of the game. Fortunately, RxJS provides us with such an operator, skip().

In fact, applesEaten$only acts as a notifier, it notifies other streams, and no observer subscribs to it. Therefore, we need to subscribe manually.

let appleEaten$ = apples$
  .skip(1)
  .do((a)= > length$.next(POINTS_PER_APPLE))
  .subscribe();
Copy the code

Integration of the code

Now that we’ve implemented all of the core code blocks in the game, we can finally assemble them into the final result flow scene$. We will use the combineLatest operator. It’s similar to withLatestFrom, but with a few differences. First, let’s look at the code:

let scene$ = Observable.combineLatest(snake$, apples$, score$, (snake, apples, score) = > ({ snake, apples, score }));
Copy the code

Unlike withLatestFrom, we don’t limit the secondary stream, we care about the new value generated by each input Observable. The final argument, again a selector function, combines all the data into an object representing the state of the game and returns the object. The game state contains all the data needed for canvas rendering.

Performance maintenance

Whether it’s a game or a Web application, performance is what we’re looking for. Performance matters a lot, but for our game, we want to redraw the entire scene 60 times per second.

We can take care of rendering by introducing another stream like Tick $. Basically, it’s just another timer:

// interval accepts time periods in milliseconds, which is why we divide 1000 by FPS
Observable.interval(1000 / FPS)
Copy the code

The problem is that JavaScript is single-threaded. In the worst case, we prevent the browser from doing anything, causing it to lock up. In other words, browsers may not be able to handle all these updates quickly. The reason is that the browser is trying to render one frame and is immediately asked to render the next frame. As a result, it drops the current frame to maintain speed. At this point, the animation starts to look a little clunky.

Fortunately, we can use requestAnimationFrame to allow the browser to queue tasks and execute them at the most appropriate time. But how do we use it in the Observable pipe? The good news is that many operators, including interval(), accept the Scheduler as their final argument. In summary, Scheduler is a mechanism for scheduling tasks to be executed in the future.

Although RxJS provides a variety of schedulers, the one we are interested in is one called animationFrame. This scheduler in the window. The requestAnimationFrame mission when triggered.

Perfect! To apply this to interval, we call the resulting Observable Game $:

// Notice the last parameter
const game$ = Observable.interval(1000 / FPS, animationFrame)
Copy the code

Interval now emits values roughly every 16ms to keep the FPS around 60.

Render the scene

All that’s left to do is combine game$and scene$. Can you guess which operator we will use? Both streams are timers, but at different intervals, and our goal is to render the game scene to the Canvas 60 times per second. We use game$as the main stream, and each time it emits a value, we combine it with the latest value in scene$. Sound familiar? Yeah, we’re going to use withLastFrom again.

// Notice the last parameter
const game$ = Observable.interval(1000 / FPS, animationFrame)
  .withLatestFrom(scene$, (_, scene) = > scene)
  .takeWhile(scene= >! isGameOver(scene)) .subscribe({ next:(scene) = > renderScene(ctx, scene),
    complete: (a)= > renderGameOver(ctx)
  });
Copy the code

You may have spotted the takeWhile() in the code above. It is another useful operator that can be called in an existing Observable. It returns the value of game$until isGameOver() returns true.

That’s it! We have completed the entire Snake game, and we have done it in a completely reactive programming manner, without any dependence on external state, using only the Observables and operators provided by RxJS.

This is an online demo.

The follow-up work

As of now, the implementation of the game is very simple, and in future articles we will expand various features, one of which will be restarting the game. In addition, we describe how to implement the pause and continue functions, as well as the different levels of difficulty.

Stay tuned!

Special thanks to

Special thanks to James Henry and Brecht Billiet for their help with the game code.