Browser Physics Using the new web standards to produce physics

31May/123

Building a physics based multiplayer game

multiplayer

This post is about how to build a simple multiplayer game using HTML5, WebSocket Node.js and Box2Djs. For client rendering, Raphael is used. The result is a physics playground where a number of users can connect and together play with boxes. The main client code is 150 rows of JavaScript and the server code is about 200.

This tutorial does not explain all of the code, but is more like an introduction to how the full code is structured and how it works. You can download the full code in the end of this post.

Video

Simplified algorithm

Server

  • Create a physics world, and make it move.
  • Whenever a client connects: start sending world information to him at a constant rate.
  • Whenever a client interacts: apply interaction on the locally created world.

Client

  • Connect to the server.
  • Whenever world information is received from the server: re-render the world.
  • Whenever the user interacts with the mouse: send mouse state to the server.

Server implementation

On the server, we will run a webserver, a WebSocket server, and a Box2D physics world. All in Node.js.

Begin with installing Node.js, and make sure you also get their package manager, NPM. Use NPM to install the express package globally:

$ sudo npm install express -g

We use the express framework to serve HTML and websocket communication. Set up a simple directory structure by running the command "express" in your target directory. This will create some files and directories for your web project. Edit your newly created package.json file to add some dependencies. Make sure it looks like this:

// package.json
{
  "name": "application-name",
  "version": "0.0.1",
  "private": true,
  "scripts": {
    "start": "node app"
  },
  "dependencies": {
    "express": "*",
    "ejs":"*",
    "websocket":"*"
  }
}

To install all dependencies, run npm install in the same directory. There are a few more things you have to change before your app can work though. Open app.js and edit some things at the top so it looks like this:

// app.js - the main server application
var express = require('express')
, http = require('http')
, WebSocketServer = require('websocket').server
, Buffer = require('buffer').Buffer;

var jQuery = require('./public/javascripts/jquery-extend.js');
Object.extend = jQuery.extend; // box2d.js needs Object.extend
var b2 = require('./public/javascripts/box2d.js');

var app = express();
app.configure(function(){
  app.set('views', __dirname + '/views');
  app.set('view engine', 'ejs');
  app.use(express.logger('dev'));
  app.use(express.static(__dirname + '/public'));
  app.use(express.bodyParser());
  app.use(express.methodOverride());
  app.use(app.router);
});
app.configure('development', function(){
  app.use(express.errorHandler());
});

What I've done is changed some of the require() calls, and added some. I've also changed view engine from "jade" to "ejs". Note that jquery-extend.js and box2d.js are tweaked versions of their original libraries. You can find these modified files in the project code in the end of this post.

When visitors requests http://domain.com:portnumber/, then we want to render views/index.ejs. We will create index.ejs later (see the client code).

app.get('/', function(req, res){
    res.render('index'); // renders views/index.ejs
});

Then we set up the Box2D world. I'll not cover how to do this here, but you can study the project code to see how it's done. The Box2D manual may come in handy. The most important line when creating the world is this one below:

var b2world = new b2.b2World(worldAABB, gravity, true);

To make the objects in the Box2D world move, we must update the world regularly to get smooth animation. To do this, we run b2world.Step() in a loop, like this:

// Simulation loop
setInterval(function(){
    b2world.Step(1/60,2,4);
}, 1.0/60.0 * 1000);

Start the webserver so we can serve HTML to the users:

// Start Webserver
var server = http.createServer(app).listen(3000);
console.log("Express server listening on port 3000");

Now start the websocket server so we can provide websocket connections. For the full code, see the project files which you can download at the end of this post.

// Start the WebSocketServer
var wss = new WebSocketServer({httpServer: server});

wss.on('request', function(req){
    // Accept connection
    var connection = req.accept(null, req.origin);

    // Send world information
    connection.send(JSON.stringify(world2json(b2world)));

    // Each user has got an own mouseJoint to play with
    var mouseJoint;

    // Message
    connection.on('message', function(message) {
        // ...handle message from client
    });

    // Send body positions to the client at 60Hz
    var interval = setInterval(function(){
        // ...send things
    }, 1.0/60.0 * 1000);

    // Close connection
    connection.on('close', function(connection) {
        clearInterval(interval); // Stop sending to the client
    });
});

The above code defines what happens when a client requests a connection. We begin by accepting the connection. Then we send a JSON object to the client describing the world, what type and shape the objects have. Then we define what happens when we get a message from the client and when the client closes the connection.

You can also see that we start a loop sending object positions to the client at a constant rate. What happens in brief is that all body positions and rotations are saved to a Buffer object, and then it is sent.

// Send body positions to the client at 60Hz
var interval = setInterval(function(){
  var bodies = [];
  for(var b = b2world.GetBodyList(); b; b = b.GetNext()){
    if(b.m_shapeCount)
      bodies.push(b);      
  }
  var buf = new Buffer(3*4*bodies.length); // (x,y,angle) * (4 bytes per number) * numBodies
  for(var i=0; i<bodies.length; i++){
    // Send body data
    var b = bodies[i];
    buf.writeFloatLE(b.m_position.x, 3*4*i + 0);
    buf.writeFloatLE(b.m_position.y, 3*4*i + 4);
    buf.writeFloatLE(b.GetRotation() % (Math.PI*2), 3*4*i + 8);
  }
  connection.send(buf);
}, 1.0/60.0 * 1000);

What happens when a message is recieved is the following:

// Message
connection.on('message', function(message) {
  switch(message.type){
  case 'utf8':
    break;
  case 'binary':
    // Move joint
    var bin = message.binaryData;
    var x = bin.readFloatLE(0),
    y = bin.readFloatLE(4);
    state = bin.readFloatLE(8);
    switch(state){
    case 0: // MouseUp - Remove mouseJoint
      if(mouseJoint){
        b2world.DestroyJoint(mouseJoint);
        mouseJoint = null;
      }
      break;
    case 1: // MouseDown - Add mouseconstraint
      // First we must find the clicked body
      var clickedBody = null;
      for(var b = b2world.GetBodyList(); b; b = b.GetNext()){
        for(var s = b.GetShapeList(); s != null; s = s.GetNext()){
          // Need to rotate the shape.
          var p = new b2.b2Vec2(x,y);
          if(s.TestPoint(p))
            clickedBody = b;
        }
      }
      if(clickedBody){
        // Attach body to a mouse joint
        var md = new b2.b2MouseJointDef();
        md.bodyA = md.body1 = groundBody;
        md.bodyB = md.body2 = clickedBody;
        md.target.Set(x,y);
        md.collideConnected = true;
        md.dampingRatio = 0.0;
        md.frequencyHz =  60.0;
        md.maxForce = 100 * md.body2.GetMass() * b2world.m_gravity.Length();
        var mj = b2world.CreateJoint(md);
        mouseJoint = mj;
      }
      break;
    case 2: // MouseMove - Move the attachment point
      if(mouseJoint)
        mouseJoint.m_target.Set(x,y);
      break;
    }
  }
});

The message is parsed, and what we get is the mouse position (in world coordinates) and its state (mouseup, mousedown, mousemove). If we caught a mousedown event, we create a b2MouseJoint and connect to the clicked body. If mouseup we release it. If the mouse is moving and we have connected the joint, we move the joint target point. This way the user can grab an object and move it around using the mouse joint.

Client implementation

On the client we will do rendering using Raphael and "stream" world data from the server through a WebSocket. Let's get started.

Create a simple HTML page views/index.ejs that we will use for running our browser app. Any structure will do, though we need to add some javascript resources before we can start playing:

<script src="javascripts/raphael.js"></script>
<script src="http://code.jquery.com/jquery.min.js"></script>
<script src="javascripts/main.js"></script>

Raphael is the SVG rendering library that we will use, your can download it from http://raphaeljs.com/. public/javascripts/main.js is the main client code. We'll create it later.

We also need a holder div element for our rendering canvas. Create one inside the document body:

<div id="holder"></div>

Now we can start with the actual client code. Start off by creating public/javascripts/main.js.

$(document).ready(function () {

    // The drawing canvas
    var r = Raphael("holder");

    // Screen dimensions
    var canvasWidth = 640;
    var canvasHeight = 480;

    var dt = 1/60; // framerate

    // World-to-screen transformation info
    var scale = 1, // No zooming
    offset_x = canvasWidth/2, // Translates world origin to canvas center
    offset_y = canvasHeight/2;

    // Mouse drag things
    var last = 0; // Time when the mouse position was last sent
    var lastx = 0, lasty = 0;
    var mousedown = false;

    // Websocket stuff
    var ws = new WebSocket("ws://your.domain.com:3000");
    var send_buf = new ArrayBuffer(4*3); // x,y,mouseState
    var send_array = new Float32Array(send_buf);

    // ...define some callbacks and functions here...
});

The above code uses jQuery.ready() to make sure the document was loaded before we run the rest of the code. Then we initialize some variables we'll need later, such as the Raphael drawing canvas, screen-to-world transformation info and variables to keep track of the mouse state.
The websocket stuff initializes a new WebSocket (Note: port 3000 is the default express port), and allocates a buffer that can be used to send three 32-bit floats to the server each time the mouse moves (two for position and one for the mouse state). The Float32Array is used to be able to set values in the buffer more simply.

Now let's add some callbacks and functions to this script so things start to happen.

Define a send function that can be used to send the mouse state to the socket. We will use this later.

function send(x,y,state){
    send_array[0] = x;
    send_array[1] = y;
    send_array[2] = state;
    ws.send(send_buf);
}

Before we send the mouse position to the server, we need to transform it into the physics world coordinates. Why not send them directly, you may ask. Well, it could be done on the server, but the server does not know anything about how your screen looks. To be able to transform, we define the following function; it comes in handy when defining mouse callbacks later.

// Transforms a mouse position relative to an object, scales and puts offset on coords
function transformMousePos(mouseEvent,relObject){
     // Get relative mouse position
    var x = mouseEvent.pageX-$(relObject).offset().left;
    var y = mouseEvent.pageY-$(relObject).offset().top;
    // Scale and translate
    x -= offset_x;
    y -= offset_y;
    x /= scale;
    y /= scale;
    return [x,y];
}

Now add some jQuery mouse events. We send mouse state to the server on the mousedown and the mouseup events. We have to be more careful on mousemove since this function is called quite frequently. Therefore we limit that to send only if the mouse position was changed, and if it was dt seconds since last send.

$("#holder").mousedown(function(e){
    var mp = transformMousePos(e,this);
    send(mp[0],mp[1],1);
    mousedown = true;
}).mouseup(function(e){
    var mp = transformMousePos(e,this);
    send(mp[0],mp[1],0);
    mousedown = false;
}).mousemove(function(e){
    var mp = transformMousePos(e,this);
    var x = mp[0], y = mp[1];
    var now = new Date().getTime();
    if(mousedown && (now-last)>dt*1000 && !(lastx==x && lasty==y)){
        send(x,y,2);
        lastx = x;
        lasty = y;
        last = now;
    }
});

When we get a message from the server, we need to parse the received data and update our canvas. We have two scenarios: if the data is binary we assume it is a Float32Array of object positions + rotations. If it is a string we assume it is a stringified JSON object describing the types of the objects in the Box2D world.

// WebSocket callbacks
ws.onmessage = function (e) {
  if(e.data && e.data instanceof Blob){
    // Update transforms. Need a filereader to read the fetched binary blob
    var fr = new FileReader();
    fr.onload = function(f){
        downStats.accumulate(fr.result.byteLength);
        var a = new Float32Array(fr.result);
        updateRenderableTransforms(a);
    }
    fr.readAsArrayBuffer(e.data);
  } else if(typeof e.data == "string"){
    // We got a world description in JSON. Parse it.
    var world = JSON.parse(e.data);
    if(world.bodies===undefined)
      throw new Error("Malformed data from server :(");
    updateRenderables(world.bodies);
  }
};

Look up how updateBodyTransforms() and updateRenderables() works in the project code.

Running the application

Start the server script, app.js by running this in your project directory:

$ node app

To connect to and view the simulation, go to http://localhost:3000/.

Conclusions

Node.js + HTML5 is great and fun for making a multiplayer game. It means quick implementation because of the high level scripting, and in some cases you can even use the same code both in server and client.
The bandwidth is a very important topic and an obvious culprit. Binary transfer hels a lot! And by using WebSocket instead of AJAX you don't have to make HTTP requests to get world data every timestep.
To try the limits I tried adding boxes and when I got to about 140 there was no visible lag - though collision detection of Box2D in the server crashed for some reason.
Node.js does not seem to care about either number of boxes or the number of connected players. I tried to connect 10 players playing with 10 boxes without any big signs of extra load on the Node.js server process. I have no numbers for you on this - but I believe that at least 100 players could be connected without putting too much load on the server.

Future work

  • The client side app has only been tested in Google Chrome 18, it should be tested in more browsers.
  • When Box2D bodies fall asleep, they will still add to the download bandwidth. A optimization goal would be to have zero download rate when all objects are sleeping.
  • Lag is handled neither by server, nor client.
  • Reduce bandwidth by reducing sync frequency and interpolating object movements instead (Box2D can indeed run on the client to do this).
  • Compress communicated data either manually or by using a WebSocket library that supports the extension "deflate-frame" natively.
  • 3D using WebGL would be cool :)

Download the code

multiplayer.tar.gz (~100.2 kb)

Thanks to

  • Erin Catto, for his splendid 2D physics engine Box2D.
  • hrj, for his modified port of Box2D to javascript.
Comments (3) Trackbacks (1)
  1. This looks very interesting to me, and simple enough that I might get something out of studying the code. Thanks for sharing! Have you looked at the virtualworldframework.com project? Seems to me that there would be a lot of potential benefit from mixing together some of your physics work with their collaboration framework. Cheers!

  2. Finally got around to trying this out! When I first tried it, my node.js and Python installs were outdated and I didn’t want to upgrade them right away. Now I have updated node.js and it turns out that WebSocket will now run without the native code, so that makes things easier for me. When I got everything installed and tried the app, it told me “Disconnected, try refreshing” or something similar, yet there were no errors in the node console. Using Wireshark, I found that it was talking to your site. In the javascripts/main.js file, when the websocket is opened, it should point to localhost instead of your site. Then it worked for me! Thanks for your work and great explanation. Cheers!

  3. Oops! Not localhost, but the server address, as you stated in your explanation.


Leave a comment


*