Building a Pacman Game With Bacon.js
JavaScript embraces asynchronous programming. This can be a blessing and a curse that leads to the concept of “callback hell”. There are utility libraries that deal with organizing asynchronous code such as Async.js, but it’s still hard to follow the control flow and reason about asynchronous code effectively.
In this article, I’ll introduce you to the concept of reactive programming that helps dealing with the asynchronous nature of JavaScript, by using a library called Bacon.js.
Let’s Get Reactive
Reactive Programming is about asynchronous data streams. It replaces the Iterator Pattern with the Observable Pattern. This is different from imperative programming, where you actively iterate over data to handle stuff. In reactive programming, you subscribe to the data and react to events asynchronously.
Bart De Smet explains this shift in this talk. André Staltz covers Reactive Programming in depth in this article.
Once you become reactive, everything becomes an asynchronous data stream: database on the server, mouse events, promises, and server requests. This lets you avoid what’s known as “the callback hell”, and gives you better error handling. Another powerful feature of this approach is the ability to compose streams together, that gives you great control and flexibility. Jafar Husain explains these concepts in this talk.
Bacon.js is a reactive programming library and it’s an alternative to RxJS. In the next sections we’ll use Bacon.js to build a version of the well-known game “Pacman”.
Setup Project
To install Bacon.js, you can use Bower by running on the CLI the command:
$ bower install bacon
Once the library is installed, you are ready to get reactive.
PacmanGame API and UnicodeTiles.js
For the look and feel, I’ll use a text-based system, so that I don’t have to deal with assets and sprites. To avoid creating one myself, I’ll employ an awesome library called UnicodeTiles.js.
To start, I’ve built a class called PacmanGame
, which handles the game logic. The followings are the methods it provides:
PacmanGame(parent)
: Creates a Pacman game objectstart()
: Starts the gametick()
: Updates the game logic, renders the gamespawnGhost(color)
: Spawns a new ghostupdateGhosts()
: Updates every ghost in the gamemovePacman(p1V)
: Moves the Pacman in the specified direction
In addition, it exposes the following callback:
onPacmanMove(moveV)
: Called if present, when user requests Pacman to move by pressing a key
So to use this API, we are going to start
the game, call spawnGhost
periodically to spawn ghosts, listen for the onPacmanMove
callback, and whenever that happens, call movePacman
to actually move Pacman. We also call updateGhosts
periodically to update the ghost movements. Finally, we call tick
periodically to update the changes. And importantly, we will use Bacon.js to help us with handling events.
Before we start, let’s create our game object:
var game = new PacmanGame(parentDiv);
We create a new PacmanGame
passing a parent DOM object parentDiv
where the game will be rendered into. Now we are ready to build our game.
EventStreams or Observables
An event stream is an observable, to which you can subscribe to observe events asynchronously. There are three types of events that you can observe for with these three methods:
observable.onValue(f)
: Listen for value events, this is the simplest way to handle events.observable.onError(f)
: Listen for error events, useful for handling errors in the stream.observable.onEnd(f)
: Listen for an event that a stream has ended, and no move value will be available.
Creating Streams
Now that we’ve seen the basic usage of event streams, let’s see how to create one. Bacon.js provides several methods you can use to create an event stream from a jQuery event, an Ajax promise, a DOM EventTarget, a simple callback, or even an array.
Another useful concept about event streams is the notion of time. That is, events can come some time in the future. For example these methods create event streams that deliver events at some time interval:
Bacon.interval(interval, value)
: Repeats thevalue
indefinitely with the given interval.Bacon.repeatedly(interval, values)
: Repeats thevalues
with given interval indefinitely.Bacon.later(delay, value)
: Producesvalue
after givendelay
.
For more control, you can roll your own event stream using Bacon.fromBinder()
. We will show this in our game by creating a moveStream
variable, that produces events for our Pacman moves.
var moveStream = Bacon.fromBinder(function(sink) {
game.onPacmanMove = function(moveV) {
sink(moveV);
};
});
We can call sink
with a value that will send an event, and which the observers can listen for. The call to sink
is within our onPacmanMove
callback – that is whenever user presses a key to request a Pacman move. So we created an observable that emits events about Pacman move requests.
Note that we called sink
with a plain value moveV
. This will push move events with the value moveV
. We can also push events like Bacon.Error
, or Bacon.End
.
Let’s create another event stream. This time we want to emit events that notify to spawn a ghost. We will create a spawnStream
variable for that:
var spawnStream = Bacon.sequentially(800, [
PacmanGame.GhostColors.ORANGE,
PacmanGame.GhostColors.BLUE,
PacmanGame.GhostColors.GREEN,
PacmanGame.GhostColors.PURPLE,
PacmanGame.GhostColors.WHITE,
]).delay(2500);
Bacon.sequentially()
creates a stream that delivers the values
with given interval. In our case, it’ll deliver a ghost color every 800 milliseconds. We also have a call to a delay()
method. It delays the stream so the events will start to emit after a 2.5 second delay.
Methods on Event Streams and Marble Diagrams
In this section I’ll list a few more useful methods that can be used on event streams:
observable.map(f)
: Maps values and returns a new event stream.observable.filter(f)
: Filters values with the given predicate.observable.takeWhile(f)
: Takes while given predicate is true.observable.skip(n)
: Skips the firstn
elements from the stream.observable.throttle(delay)
: throttles the stream by somedelay
.observable.debounce(delay)
: Throttles the stream by somedelay
.observable.scan(seed, f)
Scans the stream with given seed value and accumulator function. This reduces the stream to a single value.
For more methods on event streams see the official documentation page. The difference between throttle
and debounce
can be seen with marble diagrams:
// `source` is an event stream.
//
var throttled = source.throttle(2);
// source: asdf----asdf----
// throttled: --s--f----s--f--
var debounced = source.debounce(2);
// source: asdf----asdf----
// source.debounce(2): -----f-------f--
As you can see, throttle
is throttling the events as usual, whereas debounce
is emitting events only after the given “quiet period”.
These utility methods are simple yet very powerful, being able to conceptualize and control the streams thus the data within. I recommend watching this talk on how Netflix makes use of these simple methods to create an autocomplete box.
Observing an Event Stream
So far, we have created and manipulated the event stream, now we will observe the events, by subscribing to the stream.
Recall the moveStream
and spawnStream
we have created before. Now let’s subscribe to both of them:
moveStream.onValue(function(moveV) {
game.movePacman(moveV);
});
spawnStream.onValue(function(ghost) {
game.spawnGhost(ghost);
});
Despite you can use stream.subscribe(), to subscribe to a stream, you can also use stream.onValue(). The difference is that subscribe
will emit both three types of events we have seen before, while onValue
will only emit events that are of type Bacon.Next
. That is it will omit the Bacon.Error
and Bacon.End
events.
When an event arrives on spawnStream
(that happens every 800 ms), its value will be one of ghost colors, and we use the color to spawn a ghost. When an event arrives on moveStream
, recall that this happens when a user presses a key to move Pacman. We call game.movePacman
with the direction moveV
: that comes with the event, so the Pacman moves.
Combining Event Streams and Bacon.Bus
You can combine event streams to create other streams. There are many ways to combine event streams, here are a few of them:
Bacon.combineAsArray(streams)
: combines event streams so the result stream will have an array of values as it’s value.Bacon.zipAsArray(streams)
: zips the streams into a new stream. Events from each stream are combines pairwise.Bacon.combineTemplate(template)
: combines event streams using a template object.
Let’s see an example of Bacon.combineTemplate
:
var password, username, firstname, lastname; // <- event streams
var loginInfo = Bacon.combineTemplate({
magicNumber: 3,
userid: username,
passwd: password,
name: { first: firstname, last: lastname }
});
As you can see, we combine event streams – namely, password
, username
, firstname
and lastname
– into a combined event stream named loginInfo
using a template. Whenever an event stream gets an event, loginInfo
stream will emit an event, combining all the other templates into a single template object.
There is also another Bacon.js way of combining streams, that is Bacon.Bus()
. Bacon.Bus()
is an event stream that allows you to push values into the stream. It also allows plugging other streams into the Bus. We will use it to build our final part of the game:
var ghostStream = Bacon.interval(1000, 0);
ghostStream.subscribe(function() {
game.updateGhosts();
});
var combinedTickStream = new Bacon.Bus();
combinedTickStream.plug(moveStream);
combinedTickStream.plug(ghostStream);
combinedTickStream.subscribe(function() {
game.tick();
});
Now we create another stream – the ghostStream
, using Bacon.interval
. This stream will emit 0 every 1 second. This time we subscribe
to it and call game.updateGhosts
to move the ghosts. This is to move the ghosts every 1 second. Notice the commented out game.tick
, and remember the other game.tick
from our moveStream
? Both streams update the game, and finally call game.tick
to render the changes, so instead of calling game.tick
in each stream, we can produce a third stream – a combination of these two streams – and call game.tick
within the combined stream.
To combine the streams, we can make use of Bacon.Bus
. That’s the final event stream in our game, which we call combinedTickStream
. Then we plug
both moveStream
and ghostStream
into it, and finally subscribe
to it and call game.tick
within it.
And that’s it, we are done. The only thing left to do is to start the game with game.start();
.
Bacon.Property
and More Examples
Bacon.Property, is a reactive property. Think of a reactive property that is the sum of an array. When we add an element to the array, the reactive property will react and update itself. To use the Bacon.Property
, you can either subscribe to it, and listen for changes, or use the property.assign(obj, method) method, which calls the method
of the given object
whenever the property changes. Here is an example of how you would make use of a Bacon.Property
:
var source = Bacon.sequentially(1000, [1, 2, 3, 4]);
var reactiveValue = source.scan(0, function(a, b) {
return a + b;
});
// 0 + 1 = 1
// 1 + 2 = 3
// 3 + 3 = 6
// 6 + 4 = 10
First, we create an event stream that produces the values of a given array – 1, 2, 3, and 4 – with a 1 second interval, then we create a reactive property that is the result of a scan
. This will assign the 1, 3, 6, and 10 values for the reactiveValue
.
Find Out More and Live Demo
In this article, we’ve introduced reactive programming with Bacon.js by building a Pacman game. It simplified our game design, and gave us more control and flexibility with the concept of event streams. The full source code is available at GitHub, and a live demo is available here.
Here are some more useful links: