The web is moving very fast these days and we all know it. Today, Reactive Programming is one of the hottest topics in web development and with frameworks such as Angular or React, it has become much more popular, especially in the modern world of JavaScript. The community has undergone a massive transition from imperative programming paradigms to functional reactive paradigms. Nevertheless, many developers try to deal with this and are often overwhelmed by its complexity (large API), a fundamental shift in thinking (from imperative to declarative) and a multitude of concepts.
Although this is not the simplest topic, but as soon as we are able to understand it, we will ask ourselves how we could live without it?
This article is not intended to be introduced into reactive programming, and if you are a complete newbie to it, we recommend the following resources:
The purpose of this publication is to learn to think reactively by building a classic video game that we all know and love - Snake. That's right, a video game! This is a fun but complex system that contains a lot of external state, for example: an account, timers or player coordinates. For our version, we will widely use Observables (observables) and use several different operators to completely avoid third-party influences on the external state. At some point, you may be tempted to save the state outside the Observable stream, but remember that we want to use reactive programming and not rely on a separate external variable that preserves the state.
')
Note We will use
HTML5 and
JavaScript exclusively with
RxJS to convert the software event loop into an application based on a reactive event.
The code is available on
Github , and
here you can find a demo version. I urge you to clone the project, tinker with it a bit and implement interesting new game functions. If you do, email me on
Twitter .
Content- A game
- Scene setting
- Identification of source streams
- Snake Control
- Direction Flow ($ direction)
- Length tracking
- BehaviorSubject as salvation
- Account implementation (score $)
- Snake Taming (snake $)
- Making apples
- Putting it all together
- Maintain performance
- Scene display
- Future work
- Special thanks to
A gameAs mentioned earlier, we are going to recreate Snake, a classic video game from the late 1970s. But instead of simply copying the game, we add a little variation to it. This is how the game works.
As a player, you control a line that looks like a hungry snake. The goal is to eat as many apples as you can to grow as long as possible. Apples can be found in random positions on the screen. Every time a snake eats an apple, its tail becomes longer. The walls won't stop you! But listen, you should try to avoid getting into your own body at all costs. If you do not, the game is over. How long can you survive?
Here is a preliminary example of what we are going to do:

For this particular implementation, the snake is represented as a line of blue squares, where its head is painted black. Can you tell what the fruit looks like? Exactly, red squares. All entities are square, and this is not because they look prettier, but because they are very simple geometric figures and they are easily drawn. The graphics are not very brilliant, but hey, this is about a transition from imperative programming to reactive programming, and not about game art.
Scene settingBefore we start working with the functionality of the game, we need to create a
canvas element (canvas) that gives us powerful drawing APIs from JavaScript. We will use the canvas to draw our graphics, including the playing field, the snake, apples and basically everything we need for our game. In other words, the game will be fully displayed in the
canvas element.
If this is completely new to you, check out
this course on Keith Peters egghead.
The
index.html file is fairly simple, because most of the magic happens in javascript.
<html> <head> <meta charset="utf-8"> <title>Reactive Snake</title> </head> <body> <script src="/main.bundle.js"></script> </body> </html>
The script (script) that we add to the body (body) is essentially the result of the build process and contains all of our code. However, you may be wondering why there is no such element in the
body as
canvas . This is because we will create this element using javascript. In addition, we add several constants that define the number of
rows and
columns , as well as the
width and
height of the canvas.
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; }
With this we can call this function, create a
canvas element on the fly and add it to the
body of our page:
let canvas = createCanvasElement(); let ctx = canvas.getContext('2d'); document.body.appendChild(canvas);
Notice that we also get a reference to
CanvasRenderingContext2D by calling
getContext ('2d') on the
canvas element. This 2D rendering context for the canvas allows us to draw, for example, rectangles, text, lines, paths, and more.
We are ready to move! Let's start work on the basic mechanics of the game.
Identification of source streamsBased on the example and description of the game, we know that we need the following functions:
- Control the snake with the arrow keys
- Player Tracking
- Snake tracking (including food and movement)
- Tracking apples on the field (including the creation of new apples)
Reactive programming deals with programming based on data streams, input data streams. Conceptually, when a reactive program is executed, it sets up monitoring of the source of information, and responds to changes, such as user interaction with the application, when you press a key on the keyboard or just the next stage of the interval. So the whole thing is to understand
what can change . These
changes often define
source threads . The main task is to determine the source streams, and then put them together to calculate everything you need, for example, the state of the game.
Let's try to find our source streams by looking at the above functions.
First of all, user input will definitely change over time. The player moves the hungry snake using the arrow keys. This means that our first source thread is
keydown $ , which will trigger a value change whenever a key is pressed.
Then we need to keep track of the player's score. The score mainly depends on how many apples the snake ate. We can say that the score depends on the length of the snake, because whenever the snake grows, we want to increase the score by
1 . Therefore, our next source thread is
snakeLength $ .
Again, it is important to determine the
main streams from which we can calculate everything you need, for example, an account. In most cases, source streams are combined and converted into more specific data streams. In a minute we will see it in action. For now, let's continue defining our main streams.
At the moment we have received user input events and an account. We are left with more
game or
interactive streams, such as a snake or apples.
Let's start with the snake. The basic mechanism of a snake is simple: it moves with time, and the more apples it eats, the more it grows. But what exactly is the
flow of a snake? At the moment we can forget about what she eats and grows, because first of all it is important that she depends on
the time factor when she moves
with time , for example,
5 pixels every
200 ms . So our
source stream is an interval that sends a value after each time period, and we call it
ticks $ . This stream also determines the speed of our snake.
And last but not least: apples. Arranging apples on the field considering everything is quite simple. This stream mainly depends on the snake. Every time the snake moves, we check whether the snake’s head collides with an apple or not. If so, we remove this apple and generate a new one in an arbitrary position on the field. As mentioned, we do not need to enter a new data stream for apples.
Great, that's all for the main streams. Here is a brief overview of all the threads we need for our game:
- keydown $ : keystroke events (KeyboardEvent)
- snakeLength $ : represents the length of the snake (Number)
- ticks $ : the interval that represents the movement of the snake (Number)
These source streams form the basis for our game, from which we can calculate all the other values we need, including the score, the condition of the snake and the apples.
In the following sections, we will take a closer look at how to implement each of these source streams and apply them to generate the data we need.
Snake ControlLet's dive right into the code and implement the control mechanism for our snake. As mentioned in the previous section, control depends on keyboard input. It turns out to be terribly simple, and the first step is to create an
observable sequence from keyboard events. For this we can use the
fromEvent () operator:
let keydown$ = Observable.fromEvent(document, 'keydown');
This is our very first source thread, and it will trigger a
KeyboardEvent every time a user presses a key. Notice that literally every
keydown triggers an event. Therefore, we also receive events for the keys that do not interest us at all, and these are basically all the other keys, except for the arrow keys. But before solving this particular problem, we define a permanent map of directions:
export interface Point2D export interface Directions export const DIRECTIONS: Directions = ,
Looking at the
KeyboardEvent object, we can assume that each key has a unique
keyCode . To get the codes for the arrow keys, we can use
this table.
Each direction is of type
Point2D , which is simply an object with x and y properties. The value for each property can be
1 ,
-1 or
0 , indicating where the snake should be. Later we will use the direction to get a new grid position for the head and tail of the snake.
Direction Flow ($ direction)So, we already have a thread for
keydown events, and each time a player presses a key, we need to match the value, which is the
KeyboardEvent , to one of the direction vectors above. To do this, we can use the
map () operator to project each keyboard event onto the direction vector.
let direction$ = keydown$ .map((event: KeyboardEvent) => DIRECTIONS[event.keyCode])
As mentioned earlier, we will receive
every keystroke event, because we do not filter out those that do not interest us, for example, the character keys. However, it can be argued that we are already filtering events by viewing them on a map of directions. For each
keyCode that is not defined on this map, it will be returned as
undefined . However, this does not actually filter the values in the stream, so we can use the
filter () operator to process only the desired values.
let direction$ = keydown$ .map((event: KeyboardEvent) => DIRECTIONS[event.keyCode]) .filter(direction => !!direction)
Well, that was easy. The code above works fine and works as expected. However, there is more to improve. Have you guessed what?
Well, one idea is that we want to prevent the snake from going in the opposite direction, for example. right to left or top to bottom. In fact, it does not make sense to allow this behavior, because the rule number one is to avoid getting into your own tail, remember?
The solution is quite simple. We cache the previous direction and when a new event is triggered, we check if the new direction is different from the opposite one. Here is a function that calculates the
next (
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; }
This is the first time that we are tempted to save a state outside the Observable (
observable ) source of information, because we somehow need to correctly track the previous direction ... The easy solution is to simply save the previous direction in the external state variable. But wait! After all, we wanted to avoid this, right?
To avoid an external variable, we need a way to sort the aggregate infinite Observables. RxJS has a very convenient operator that we can use to solve our problem -
scan () .
The
scan () operator is very similar to
Array.reduce () , but instead of only returning the last value, it initiates the sending of each intermediate result. With
scan (), we can basically accumulate values and infinitely reduce the flow of incoming events to a single value. Thus, we can track the previous direction, without relying on the external state.
Let's apply this and look at our final
direction $ stream:
let direction$ = keydown$ .map((event: KeyboardEvent) => DIRECTIONS[event.keyCode]) .filter(direction => !!direction) .scan(nextDirection) .startWith(INITIAL_DIRECTION) .distinctUntilChanged();
Note that we use
startWith () to initiate the initial value before starting to send values from the source Observable (
keydown $ ). Without this operator, our Observable will start sending values only when the player presses the key.
The second improvement is to initiate the sending of values only when the new direction differs from the previous one. In other words, we only need
different values . You may have noticed in the above fragment
distinctUntilChanged () . This operator does the dirty work for us and suppresses duplicate items. Note that
distinctUntilChanged () only filters out the same values, unless another is selected between them.
The following diagram visualizes our flow
direction $ and how it works. The values colored blue represent the initial values, yellow means that the value was changed on the Observable stream, and the values sent in the
result stream are orange.
Length trackingBefore we implement the snake itself, let's figure out how to track its length. Why do we need length first? Well, we use this information for account modeling. It would be correct to say that in the imperative world we simply check whether there was a collision whenever the snake moves, and if so, we increase the score. Thus, there is actually no need to track the length. However, such an approach would introduce another external state variable, which we want to avoid at any cost.
In the jet world, the solution is slightly different. One simple approach may be to use the
snake $ stream, and each time it sends a value, we know that the snake has increased in length. Although it really depends on the implementation of
snake $ , as long as we do not implement this stream. From the very beginning, we know that the snake depends on
ticks $ because it moves a certain distance over time. Thus, snake $ will accumulate an array of body segments, and since it is based on
ticks $ , it will generate a value every
x milliseconds. However, even if the snake does not encounter anything,
snake $ will still send values. This is because the snake is constantly moving across the field, and therefore the array will always be different.
This can be a bit tricky to understand, because there are certain dependencies between different threads. For example,
apples $ will depend on
snake $ . The reason for this is that every time a snake moves, we need an array of segments of its body to check if any of these parts collide with an apple. While the
apples $ thread will accumulate an array of apples, we need a collision modeling mechanism, which at the same time avoids circular dependencies.
BehaviorSubject as salvationThe solution to this problem is that we will implement
the broadcast mechanism using the
BehaviorSubject . RxJS offers various types of Subjects (
items ) with different functionalities. Thus, the
Subject class provides the basis for creating more specialized
Subjects . In a nutshell, a Subject is a type that implements both the Observer (
Observer ) and Observable (Observable) types. Observables define data flow and create data, while Observers can subscribe to Observables and receive data.
BehaviorSubject is a more specialized Subject providing a value that changes over time. Now, when the Observer subscribes to the
BehaviorSubject , it will receive the last value sent, and then all subsequent values. Its uniqueness lies in the fact that it includes the
initial value , so that all Observers will receive at least one value when subscribing.
Let's continue and create a new
BehaviorSubject with an initial value of
SNAKE_LENGTH :
// SNAKE_LENGTH specifies the initial length of our snake let length$ = new BehaviorSubject<number>(SNAKE_LENGTH);
From this place there was only a small step to implement snakeLength $:
let snakeLength$ = length$ .scan((step, snakeLength) => snakeLength + step) .share();
In the above code, we see that
snakeLength $ is based on
length $ , which is our
BehaviorSubject . This means that whenever we pass a new value to a Subject using
next () , it will send the value to
snakeLength $ . In addition, we use
scan () to accumulate length over time. Cool, but you might be wondering what kind of
share () it is , isn't it?
As already mentioned,
snakeLength $ will later be used as input for
snake $ , but at the same time acts as the source stream for the player’s account. As a result, we will eventually
recreate this source stream with a second subscription to the same Observable. This is because the
length $ is
cold Observable (
cold Observable ).
If you are completely unfamiliar with hot and cold Observables (
hot and cold observers ), we wrote an article about the
Cold vs Hot Observables .
The point is that we use
share () to allow multi-subscriptions to Observable, which would otherwise re-create its source with each subscription. This statement automatically creates a Subject between the source and all future subscribers. As soon as the number of subscribers goes from zero to one, it connects the Subject to the Observable base source and initiates sending all its notifications. All future subscribers will be connected to this intermediate Subject, so it is effective that there is only one subscription to the cold Observable below. This is called
multicasting (multicast) and will help you to stand out.
Awesome! Now that we have a mechanism that we can use to transfer values to several subscribers, we can go ahead and realize a
score $ .
Account implementation (score $)The implementation of a player’s account is as simple as possible. Armed with
snakeLength $ , we can now create a stream, the
score $ , which simply accumulates the player’s account using
scan () :
let score$ = snakeLength$ .startWith(0) .scan((score, _) => score + POINTS_PER_APPLE);
In essence, we use
snakeLength $, or rather,
length $ , to notify subscribers that a collision has occurred, and if this was the case, we simply increase the score by
POINTS_PER_APPLE , the constant number of points per apple. Note that
startWith (0) must be added before
scan () to avoid specifying an initial value.
Let's look at a more visual representation of what we have just implemented:

Looking at the above diagram, you may wonder why the initial value of the
BehaviorSubject appears only on
snakeLength $ and is missing in the
score $ . This is due to the fact that the first subscriber will force
share () to subscribe to the underlying data source and, since the original data source immediately sends the value, this value will already be sent by the time subsequent subscriptions have occurred.
Wonderful. From this point on, let's implement the flow for our snake. Isn't it exciting?
Snake Taming (snake $)By this moment we have already learned a lot of operators and now we can use them to implement our
snake $ stream. As discussed at the beginning of this article, we need some kind of
ticker that keeps our hungry snake moving. It turns out that there is a convenient operator for this
interval (x) , which sends values every
x milliseconds. Let's call each value a
tick .
let ticks$ = Observable.interval(SPEED);
From this moment until the realization of the final thread,
snake $ is quite a bit. For each tick, depending on whether the snake has eaten an apple or not, we want to either move it forward or add a new segment. Therefore, we can use the
scan () function already familiar to us to accumulate an array of body segments. But, as you may have guessed, a question arises before us. Where
do $ direction or
snakeLength $ threads come into play?
Absolutely legitimate question. The direction, like the length of the snake, is easily accessible inside our
snake $ stream, if we store this information in a variable outside the observed stream. But again, we violate our rule not to change the external state.
Fortunately, RxJS offers another very convenient operator named
withLatestFrom () . This is the operator used to join the threads, and this is exactly what we are looking for. This operator applies to the primary stream, which controls when data will be sent to the result stream. In other words, you can think of
withLatestFrom () as a way to regulate the sending of secondary stream data.
Considering the above, we have the tools necessary for the final implementation of a hungry
snake $ :
let snake$ = ticks$ .withLatestFrom(direction$, snakeLength$, (_, direction, snakeLength) => [direction, snakeLength]) .scan(move, generateSnake()) .share();
—
ticks$ , , ,
direction$ ,
snakeLength$ . , , , ,
.
,
(
)
withLatestFrom , ,
. , , .
move() , . ,
GitHub .
, :
direction$ ? ,
withLatestFrom() ,
, Observable (
),
.
, , . , .
,
direction$ ,
snakeLength$ ,
score$ snake$ . , . , . .
, . -, , . , , . . ?
,
scan() . , , , , . ,
.
distinctUntilChanged() .
let apples$ = snake$ .scan(eat, generateApples()) .distinctUntilChanged() .share();
Cool! , ,
apples$ , , . , ,
snake$ ,
snakeLength$ , , .
, ? .
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; }
length$.next(POINTS_PER_APPLE) . , ( ES2015). ES2015 , . , , .
,
applesEaten$ .
apples$ , , -
,
length$.next() .
do() , .
. -
() ,
apples$ . , , . , RxJS ,
skip() .
,
applesEaten$ , .
.
let appleEaten$ = apples$ .skip(1) .do(() => length$.next(POINTS_PER_APPLE)) .subscribe();
, , , —
scene$ .
combineLatest .
withLatestFrom , . -, :
let scene$ = Observable.combineLatest(snake$, apples$, score$, (snake, apples, score) => ({ snake, apples, score }));
, , ,
Observables (
) . , , . , .

, - .
, 60 .
, ,
ticks$ , . :
// Interval expects the period to be in milliseconds which is why we devide FPS by 1000 Observable.interval(1000 / FPS)
, JavaScript . , . , . , , . , . .
,
requestAnimationFrame , . Observable? , ,
interval() ,
Scheduler (
). ,
Scheduler — ,
- .
RxJS , , ,
animationFrame .
window.requestAnimationFrame .
Fine! , Observable
game$ :
16 , 60 FPS.
game$ scene$ . , ? , , , 60 .
game$ , , ,
scene$ . ? ,
withLatestFrom .
// Note 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: () => renderGameOver(ctx) });
,
takeWhile() . , Observable. game$ ,
isGameOver() true .
:

, , . , ,
,
.
Be in touch!
James Henry Brecht Billiet .