λ Blog.

Let's build a multiplayer Fallout 4 map using RxJS!

Cover Image for Let's build a multiplayer Fallout 4 map using RxJS!
Kyle Kelley
Kyle Kelley

We've done Pip-Boy discovery and relay and created new libraries. Now let's put them to good use. The first half of this tutorial does not require having a copy of Fallout 4 because we'll start by simulating data.

Things we'll cover:

  • Simulating data
  • Observables and RxJS
  • Bundling code for the browser via browserify and babel
  • Drawing on canvas

reactive map

Tools required

You need node 5+ and npm 3 to follow this tutorial. If you're gripping tightly to an older node version, try out nvm or n for all your node environment switching needs.

Generating Random Players

Set up a new project for this, by creating a new directory, changing to that directory, and running npm init. You can accept the defaults if you like, or mix it up.

mkdir multi-pip-fun
cd multi-pip-fun
npm init

We're making a map of players, so let's start by generating some random players. We're going to get the list of player names that codsworth can speak from.

For this tutorial, I'll use the package clean-codsworth-names. You can install it too:

npm install --save clean-codsworth-names

You can also use the raw list, which has plenty of words to make the juvenile in us giggle.

npm install --save codsworth-names

Now, create fakes.js, setting up new players and where they are on the map.

const codsworthNames = require("clean-codsworth-names");
const mapSize = 2048;
 
// newPlayer creates a new player with a name
// and some random coordinates
function newPlayer(name) {
  return {
    name: name,
    x: Math.round(Math.random() * (mapSize - 1)),
    y: Math.round(Math.random() * (mapSize - 1)),
  };
}
 
// create a set of players with coordinates
const players = codsworthNames.map(newPlayer);
 
// Just to try this out, print out a few of these
console.log(players.slice(0, 4));
$ node fakes.js
[ { name: 'Aaliyah', x: 533, y: 81 },
  { name: 'Aaron', x: 612, y: 1091 },
  { name: 'Abigail', x: 422, y: 1918 },
  { name: 'Abram', x: 210, y: 541 } ]

Give each of these players a unique ID and provide an adjective for their name (for example, Crazy Dave) by relying on two more packages: uuid (for unique IDs) and adjectives (like it says on the tin)

npm install --save uuid adjectives

Now we'll require these libraries and modify newPlayer:

const uuid = require("uuid");
const adjectives = require("adjectives");
 
// Upper case the names
const Adjectives = adjectives.map((adj) => {
  return (
    adj.charAt(0).toUpperCase() + adj.slice(1)
  );
});
 
// Simple grabber of a random element from an array
const choice = (arr) =>
  arr[Math.floor(Math.random() * arr.length)];
 
function newPlayer(name) {
  const adj = choice(Adjectives);
 
  return {
    name: `${adj} ${name}`,
    id: uuid.v4(),
    x: Math.round(Math.random() * (mapSize - 1)),
    y: Math.round(Math.random() * (mapSize - 1)),
  };
}

We've got some interesting characters already

$ node fakes.js
[ { name: 'Exultant Aaliyah', x: 113, y: 403 },
  { name: 'Responsible Aaron', x: 1840, y: 1228 },
  { name: 'Impartial Abigail', x: 704, y: 1093 },
  { name: 'Small Abram', x: 223, y: 1163 } ]

Go on a random walk

How can we simulate players moving?

By taking them on a random walk. On each iteration of the simulation, we'll add 1, 0, or -1 to the player's x and y positions.

let's go on a stroll

There are a few ways to do this and we'll rely on some mathematical properties to make this fast.

We can randomly acquire 0 and 1 by using Math.round(Math.random()). To get -1, use the mathematical property that cos(π) = -1 and cos(0) = 1. Multiply that by Math.round(Math.random()) and we have a nice little formula for generating -1, 0, and 1.

const change =
  Math.cos(Math.PI * Math.round(Math.random())) * // -1 or 1
  Math.round(Math.random()); // 0 or 1

Now we just have to apply it to a given point

// Random walk -1, 0, 1
function walk(pt) {
  const change =
    // -1 or 1
    Math.cos(Math.PI * Math.round(Math.random())) *
    Math.round(Math.random()); // 0 or 1
  const newPt = pt + change;
  if (newPt < 0 || newPt >= mapSize) {
    return pt;
  }
  return newPt;
}

Try it out on one of the characters by appending this to the end of fakes.js:

const player = players[0];
console.log(player);
player.x = walk(player.x);
player.y = walk(player.y);
console.log(player);
$ node fakes.js
{ name: 'Mountainous Aaliyah', x: 312, y: 1506 }
{ name: 'Mountainous Aaliyah', x: 311, y: 1506 }

Keep those players moving!

To generate a continuous stream of player data, we're going to create and learn about Observables through RxJS.

npm install --save @reactivex/rxjs

Down the observable rabbit hole

Join the rabbit

If you've worked with JavaScript in the front end (or the back end for that matter), you've gotten used to these core Objects:

Single return valueMultiple return values
Pull / Synchronous
Interactive
Object Iterables like Array, Set, Map, Object
Push / Asynchronous
Reactive
Promise ????

One of my favorite additions to JavaScript is the Promise. It makes a lot of asynchronous code really clean, especially with Promise chains.

fetch("/players.json")
  .then((resp) => response.json())
  .then(action)
  .catch(whoa);

What about cases where we want a promise to yield multiple values? Do we wait for all the values to get computed, relying on Promise.all()? Not if we want the intermediate values. In the end we'd have to fall back on createEvent/EventEmitter.

On top of that, we usually want to perform filtering and routing based on those messages for separate consumers of the data. What we really need is a stream of messages that we can operate on like an Array, similar to how we created new players:

const players = codsworthNames.map(newPlayer)

There just has to be something to fill that missing piece of the table. Turns out, there's one more data type to come to a JavaScript near you: Observable

Single return valueMultiple return values
Pull / Synchronous
Interactive
Object Iterables like Array, Set, Map, Object
Push / Asynchronous
Reactive
Promise Observable

Observables are asynchronous data streams, from the future.

Technically, this pattern has been around for a while. People have been using Observables in Ruby, across languages through Reactive Extensions, libraries like Bacon, and many more. Where it really shines in Javascript is with RxJS.

Both node's EventEmitter and RxJS's Observable are implementations of the Observer design pattern. The one big difference you'll see is how you can operate on Observables like core primitives. While working on pipboylib, several contributors highlighted how wonderful it would be if our interface was based on RxJS.

Here's an example of what it looks like to get the player position continuously:

pipboydb
  .map((x) => x.Map.World.Player)
  .map((x) => ({
    x: x.X,
    y: x.Y,
    deg: x.Rotation,
  }))
  .distinctUntilChanged()
  .subscribe((x) => {
    console.log("Player Position:", x);
  });

As the game updates, we see the change come into our subscription callback and display our position over time. You can even use this to write out your localmap.

In pipboy we use this to display your local map continuously like some sort of crazy sonar.

localmap cray cray

Let's learn more about Observables so you can do real-time apps like this too.

Learn some Rx

Let's learn how to work with Observables. Pop open a node terminal and require('rx'):

> var Rx = require('rx')

Start with a simple Observable that emits a new value on each interval.

> var obs = Rx.Observable.interval(10)

To subscribe to the event stream from this Observable, we use subscribe. To make sure that we don't get overwhelmed with the stream, we'll chain with the take operator, making our resulting subscription only get a few values.

> var o = obs.take(4).subscribe(console.log)
0
1
2
3

We can operate on this stream, too, mapping the values. This interval Observable is giving us an incrementing counter over time. Let's map the values to something new that we'll subscribe to.

> o = obs.map(x => x*10)
         .take(4)
         .subscribe(console.log)
0
10
20
30

Since we use take, the stream completes after 4 elements are emitted, which allows us to perform a reduction:

> var o = (
  obs.take(4)
     .reduce((x,y) => x + y, 0)
    .subscribe(console.log)
  )
6
> // Result comes from 0 + 1 + 2 + 3

How could we operate on an infinite stream? For this, Rx provides the scan operator to emit intermediates.

> var o = (
  obs.scan((x,y) => x + y, 0)
     .take(4)
     .subscribe(console.log)
  )
0
1
3
6

This is enough tooling for us to start generating simulated player data. We'll also use this to great effect when aggregating multiplayer data. For a more thorough introduction to Reactive Programming, check out the Introduction to RP you've been missing.

Generate player data with RxJS

Let's start by creating a single live player who updates themselves on an interval. Add this to fakes.js:

function livePlayer(player, period) {
  if (!player) {
    throw new Error("need a player");
  }
  if (!period) {
    period = 500;
  }
  return Rx.Observable.interval(period).scan(
    (p) => {
      return {
        name: p.name,
        id: p.id,
        x: walk(p.x),
        y: walk(p.y),
      };
    },
    player
  );
}

To create this for all the players, we'll use the merge operator to bring lots of live players together. merge takes N many Observables and combines them into one Observable stream.

// create a set of players with coordinates
const defaultPlayers =
  codsworthNames.map(newPlayer);
 
function livePlayers(players, period) {
  if (!players) {
    players = defaultPlayers;
  }
  if (!period) {
    period = 500;
  }
 
  return Rx.Observable.merge(
    ...players.map((p) => livePlayer(p, period))
  );
}

Let's export this function for use by node. Try this out by appending this to the bottom of fakes.js:

livePlayers(players)
  .take(4)
  .subscribe((x) => console.log(x));

Then, run fakes.js to get output like the following:

{ name: 'Robust Aaliyah',
  id: 'dbfb8fdc-6930-4c59-a849-144f47a5fd93',
  x: 900,
  y: 1198 }
{ name: 'Alleged Aaron',
  id: '8bb2734c-8699-493a-bf57-fecf1a135077',
  x: 1648,
  y: 484 }
{ name: 'Tremendous Abigail',
  id: '749744d2-5e8e-4737-bd57-2aef0c1dae23',
  x: 829,
  y: 450 }
{ name: 'Womanly Abram',
  id: '7e6b1614-efc9-4549-8673-4ddc0ea62531',
  x: 920,
  y: 131 }

Now our players are being generated. It's time to put them on the screen.

browserify logo

To convert our command line scripts to something we can put up on the web for everyone, we're going to use a bundler called browserify to package all the JavaScript goodness into one file, and then ensure that all of the JavaScript works on all the various browsers by using babel via babelify.

npm install --save-dev browserify babelify babel-preset-es2015

In your package.json, add build to your scripts section and create a babel section:

"scripts": {
  "build": "browserify index.js -t babelify -o bundle.js"
},
"babel": { "presets": ["es2015"] },

Next, write an index.html:

<html>
  <head>
    <meta charset="utf-8" />
  </head>
  <body>
    <script
      type="text/javascript"
      src="bundle.js"
      charset="utf-8"
    ></script>
  </body>
</html>

This sets up loading the bundle within the main page. For the index.js file, start with something fairly simple:

const fakes = require("./fakes");
 
fakes
  .livePlayers()
  .take(1)
  .subscribe((player) => {
    console.log(player);
    document.write(JSON.stringify(player));
  });

Now run npm run build. That should build your sources. To see your work in action, open up index.html in the browser. You should see a generated player.

{
  "name": "Robust Aaliyah",
  "id": "dbfb8fdc-6930-4c59-a849-144f47a5fd93",
  "x": 900,
  "y": 1198
}

Great! Now let's build that map.

Build the map

The steps, roughly:

  • Create a canvas
  • Paint a background image
  • Plot our points

Create a canvas

We're going to start small and simply put the canvas right in the index.html file.

<html>
  <head>
    <meta charset="utf-8" />
  </head>
  <body>
    <canvas id="map" width="2048" height="2048" />
    <script
      type="text/javascript"
      src="bundle.js"
      charset="utf-8"
    ></script>
  </body>
</html>

Paint an image onto the canvas

Let's create a file called mapping.js. We'll start it off by drawing an image onto a provided canvas:

const MAP_SIZE = 2048;
 
function paint(canvas, image, players) {
  const ctx = canvas.getContext("2d");
  ctx.save();
 
  if (image) {
    ctx.globalCompositeOperation = "source-over";
    ctx.drawImage(image, 0, 0);
  } else {
    ctx.clearRect(0, 0, MAP_SIZE, MAP_SIZE);
  }
 
  // Note: we're not painting players yet
 
  ctx.restore();
}
 
module.exports = {
  paint,
};

Now call paint to display our image:

const fakes = require("./fakes");
const paint = require("./mapping").paint;
 
const mapCanvas = document.getElementById("map");
 
var image;
 
const imageEl = new Image();
imageEl.src = "CompanionWorldMap.png";
imageEl.onload = () => {
  image = imageEl;
  paint(mapCanvas, image);
};

We'll need the world map for loading, so download the companion world map to your workspace.

Run npm run build again and open index.html again. You'll see that friendly commonwealth.

commonwealth

Now take all that player data and show it on screen. Within index.js, after the setup for the image let's start taking that fake player data.

fakes
  .livePlayers(fakes.defaultPlayers, 10)
  .scan((players, player) => {
    // collect the latest data for each player
    return players.set(player.id, player);
  }, new Map())
  // To render at approximately 60fps, we need to
  // throttle by ~16.66667 ms
  // 1 second = 1000 ms
  //   => 1000/60 = 16.6667 ms between frames
  .throttleTime(17)
  .subscribe((players) => {
    paint(mapCanvas, image, players);
  });

Now, the only thing we have to add to our mapping.js is plotting those players

function paint(canvas, image, players) {
  const ctx = canvas.getContext("2d");
  ctx.save();
 
  if (image) {
    ctx.drawImage(image, 0, 0);
  } else {
    ctx.clearRect(0, 0, MAP_SIZE, MAP_SIZE);
  }
 
  // Each player gets plotted with a unique color
  for (var player of players.values()) {
    ctx.fillStyle = "#" + player.id.slice(0, 6);
    ctx.fillRect(player.x, player.y, 2, 2);
  }
  ctx.restore();
}

There you have it, a live updating map!

animated dots

Be friendly with the browser render cycle

Browsers provide a global function called requestAnimationFrame that lets you call a function to update an animation before the next browser repaint. We need to rely on this to trigger the actual call to paint.

fakes
  .livePlayers(fakes.defaultPlayers, 10)
  .scan((players, player) => {
    // collect the latest data for each player
    return players.set(player.id, player);
  }, new Map())
  // We can lower our throttle since we're using rAF
  .throttleTime(10)
  .subscribe((players) => {
    window.requestAnimationFrame(
      paint.bind(null, mapCanvas, image, players)
    );
  });

Summary

We've learned how to simulate data through the use of RxJS Observables, rely on browserify, babel, and npm to package our app, and draw points on a canvas. If you liked this, let me know if you want to learn how to build the full multiplayer map.