Gaming

StackHack: Massively Multiplayer WebGL Game

Michael Carroll on Feb 1, 2012
StackHack: Massively Multiplayer WebGL Game

Warning

We've updated our SDKs, and this code is now deprecated.

Good news is we've written a comprehensive guide to building a multiplayer game. Check it out!

We recently gave the multiplayer WebGL game StackHack a major facelift, and we recommend checking out that blog post here: Making Interactive WebGL Applications: StackHack 2.0.

A Massively Multiplayer WebGL Game Mashup of PubNub and Three.js

multiplayer webgl game With the emergence of HTML5, WebGL, and other browser-based 3D technologies, the way we think about browsers is quickly shifting. To me, making bad ass graphics in a browser is a rad prospect, but a hard one. Unless you have a pretty deep understanding of shader, vertex buffers, matrix transformations and the like, it can be overwhelming just to approach it.

Personally, I took an extremely-difficult but poorly-taught1 graphics course in college back in 2009, and while I pulled out a decent grade, I've already forgotten a lot of it. Nowadays I consider myself more of a generalist. But I remembered a speaker2 in a WebGL camp conference I attended back in June recommending a certain library called three.js, which he said was great for beginners. So I looked into mrdoob‘s library three.js.

Three.js

Three.js is essentially an abstraction layer for a variety of web 3D technologies. Creating shapes and setting up cameras is as easy as defining JavaScript objects. It's renderer-agnostic, but out of the box, it supports canvas, svg and WebGL. 3 When I first saw mrdoob's project voxels I was dying to mash it up with PubNub and turned into a massively-multiplayer game. And as it turned out, he had an open-source, MIT-licensed version of it in the examples directory called Canvas Interactive Voxelpainter. But it uses the Canvas renderer, as opposed to WebGL. That's ok for the purposes of this experiment.4

StackHack is the result of that idea. Go play with it and come back when and if you want to delve into the proverbial details.

The Deets

The Deets

Let's start with the server. I used Node.js with express to serve up our HTML, CSS, JavaScript et all. When a client connects, we generate a UUID, append some stuff and listen on that channel. Why do it this way? Why not just use a generic PubNub channel? Excellent question. 5 I wanted what's known as an authoritative server.

Authoritative vs Non-Authoritative Server

In an non-authoritative server, a player would say “hey everyone, I'm making a block here.” And everyone else would have to accept it. A player could ignore it, but then their environment would be different than everyone else's. Or a player could say “hey, I'm making (or deleting) a thousand blocks. Your creation is now gone.” There's no referee to stop that type of behavior. Conversely, with an authoritative server, a player would instead say “hey, I would like to create a block here” and then wait a few milliseconds for the server to authorize it.

I once listened to Mozilla Evangelist Rob Hawkes give a talk on his project Rawkets (essentially a massively-multiplayer version of Asteroids) wherein he outlined his early, non-authoritative version of his server. Within hours, players had hacked the game to make giant ships, turbo-speed weaponry, and even cluster bombs. For StackHack, even though there are essentially no rules, I wanted this same sort of protection from cheating.

Here's some server-side code where I initialize the pubnub bits using our npm module:

1
2
3
4
5
6
7
8
9
var pubnub = require('pubnub');
var network = pubnub.init({
publish_key : "MY_PUBLISH_KEY",
subscribe_key : "MY_SUBSCRIBE_KEY",
secret_key : "MY_SECRET_KEY",
ssl : false,
origin : "pubsub.pubnub.com"
});

Now let's load that into a Node's event system:

1
2
3
4
5
6
7
8
9
10
11
var events = new (require('events').EventEmitter);
network.subscribe({
channel : "stackhack_from_client_" + client.uuid,
callback : function(message) {
events.emit(client.uuid + "_" + message.name, message);
},
error : function(e) {
console.log(e);
}
});

So we can catch incoming messages of type “status” like this:

1
2
3
4
5
6
7
app.get('/', function(req, res) {
// ... (some stuff omitted)
// the important part is that a client object is added and we generate a uuid
events.on(client.uuid + "_status", function(message) {
// here we handle the status messages from a particular client, contained in message.data
};
}

On the server, if we want to send something to a particular player, we use the following functions:

1
2
3
4
5
6
7
8
9
10
11
12
function messageClient(client_uuid, name, data, cb) {
var message = { "name": name,
"data": data };
network.publish({
channel : "stackhack_from_server_" + client_uuid,
message : message,
callback : function(info) {
if (cb != undefined) cb(info);
}
});
}

Then we render the page to the client and provide them the UUID. Here we render the page with Express and node-jqtpl:

1
2
3
4
5
6
7
8
9
10
11
12
var app = require('express').createServer();
app.configure(function() {
app.use(app.router);
app.set("view engine", "html");
app.set('view cache', false);
app.register(".html", require("jqtpl").express);
});
app.get('/', function(req, res) {
// ... a bunch of player bookkeeping stuff omitted
res.render('main', {'layout': true, 'uuid': new_uuid, 'time_til_wipe': getTimeTilWipe() });
}

Jumping to the client-side JavaScript, the client takes this UUID, listens on the right channel, and loads it into the pubnub event system:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
PUBNUB.subscribe({
channel : "stackhack_from_server_${uuid}",
callback : function(message) {
console.log("got from server: " + JSON.stringify(message));
PUBNUB.events.fire("got_from_server_" + message.name, message);
},
error : function(e) {
console.log(e);
}
});
PUBNUB.events.bind('send_to_server', function(message) {
PUBNUB.publish({
channel : "stackhack_from_client_${uuid}",
message : message,
error : function(e) {
console.log(e);
}
});
console.log("sent to server: " + JSON.stringify(message));
});

That's how clients will communicate with our authoritative node server. In this way, we prevent other users from pretending to be a client they're not. I perpended a “from_server” and “from_client” to keep things from getting confusing. In essence, I'm turning a bidirectional connection into two separate one-directional sockets. Perhaps this isn't the ideal way to do it, but it works for this use case regardless and it keeps things clean.

Let's dive into some three.js code. Here's how we set up the grid. This is taken completely from mrdoobs' original “voxels”.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// Grid
var geometry = new THREE.Geometry();
geometry.vertices.push( new THREE.Vertex( new THREE.Vector3( - 500, 0, 0 ) ) );
geometry.vertices.push( new THREE.Vertex( new THREE.Vector3( 500, 0, 0 ) ) );
var material = new THREE.LineBasicMaterial( { color: 0x000000, opacity: 0.2 } );
for ( var i = 0; i <= 20; i ++ ) {
var line = new THREE.Line( geometry, material );
line.position.z = ( i * 50 ) - 500;
scene.add( line );
var line = new THREE.Line( geometry, material );
line.position.x = ( i * 50 ) - 500;
line.rotation.y = 90 * Math.PI / 180;
scene.add( line );
}

In order to create a block, the client uses the following functions. Note that x, y, and z are the coordinates, while c is the color, and o is the opacity.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
function addBlockToScene(x, y, z, c, o) {
var material = new THREE.MeshLambertMaterial( { color: c, opacity: o, shading: THREE.FlatShading } );
var block = new THREE.Mesh( new THREE.CubeGeometry( 50, 50, 50 ), material );
block.material = material;
block.position.x = x;
block.position.y = y;
block.position.z = z;
block.matrixAutoUpdate = false;
block.updateMatrix();
block.overdraw = true;
scene.add( block );
return block;
}
function createBlock(x, y, z, c, o) {
// make a "hash" of the coordinates
var place = blockHash(x, y, z);
// block_index is the JavaScript object in which we store the block data
if (!block_index[place]) {
var block = addBlockToScene(x, y, z, c, o);
block_index[place] = block;
}
}

But we want to create a block only if the server authorizes it. So when we click, we'll add a block with an opacity at .8 and set a setTimeout; if we don't get a server acknowledgment within 5 seconds, we'll remove the block. If we do get an acknowledgment, we'll clear that timeout and set the block's opacity to 100%.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// when a player clicks, do this
createBlock(cursor.position.x, cursor.position.y, cursor.position.z, color, .8);
// we store all pending (unapproved) block requests in an object called pending_blocks
pending_blocks[place].removal_timeout = setTimeout( blockTimeout, 5000, place);
// after 5 seconds with no acknowledgement
function blockTimeout(place) {
scene.remove(block_index[place]);
delete block_index[place];
clearInterval(pending_blocks[place].recheck_interval);
delete pending_blocks[place];
}
PUBNUB.events.bind( "got_from_server_create", function(message) {
place = blockHash(message.data.loc[0], message.data.loc[1], message.data.loc[2]);
if (pending_blocks[place] != undefined) {
clearTimeout(pending_blocks[place].removal_timeout);
// recheck_interval is there because behind the scenes,
// a player kind of spams the server until it gets an acknowlegment
clearInterval(pending_blocks[place].recheck_interval);
delete pending_blocks[place];
block_index[place].material.opacity = 1;
}
else {
// that means someone else created this block
createBlock(message.data.loc[0], message.data.loc[1], message.data.loc[2], message.data.color, 1);
}
});

We also have a server supplied-total wipeout every 10 minutes.

On the server:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
var minutes_between_wipes = 10;
function getTimeTilWipe() {
var now = +new Date;
time_til_wipe = (wipe - now);
return time_til_wipe;
}
function wipeIt() {
console.log("wipe!");
block_index = {};
wipe = +new Date + (minutes_between_wipes * 60 * 1000);
messageAllClients("wipe", {"next": getTimeTilWipe()});
}
wipeIt(); // do it once to start
setInterval( function() {
wipeIt();
}, minutes_between_wipes * 1000 * 60);

On the client:

1
2
3
4
5
6
7
8
9
10
function removeAllBlocks() {
for (block in block_index) {
scene.remove(block_index[block]);
delete block_index[block];
}
}
PUBNUB.events.bind( "got_from_server_wipe", function(message) {
removeAllBlocks();
});

And that's the gist of it. Have fun!

Footnotes

1: For the record, it was a visiting professor, and I was generally very happy with the level of instruction at my alma mater. I gave him hell on the end-of-semester evaluation.

2: It was Aleksandar Rodic, who had a really cool demo of jellyfish.

3: I recently read a fascinating blog article in which someone added CSS 3D support to make some awesome native 3D scrolling.

4: It'd be a fun exercise to plug in the WebGL renderer to see if it works. At the time of writing, it wasn't quite as simple as changing one line.

5: In fact, this has become a heated topic at our office.

Get Started
Sign up for free and use PubNub to power interactive three.js