Tutorials‎ > ‎

Creating Multiplayer Web Game Using Websocket, Node.js, and Socket.io - Part 1 - Server Side

posted Nov 27, 2013, 9:27 PM by Arden Sagiterry Setiawan   [ updated Aug 15, 2016, 11:27 PM by Surya Wang ]

Introduction

This is the first part of our 2-part tutorial "Creating Multiplayer Web Game Using Websocket, Node.js, and Socket.io". You can see the second part here.

This tutorial aims to create a real-time 2D shooting game using client-server technology. Our main technology would be the new Websocket capabilities given by HTML 5 standard. Websocket is different from our conventional HTTP connection in which it provides full-duplex socket connection. Thus online multiplayer games using HTML 5 Websocket can compete with desktop application games.

This tutorial assumes that you have a fairly good understanding of basic Javascript and HTML 5 concepts. Explanations would focus more on the application concept as a whole, as well as how to use Node.js and Socket.io as our Websocket interface.

Requirements

The following applications and resources are required to be available in your system

Game Server Concept

In a typical multiplayer game server, a game client only acts as data retriever. Game server is the one which do computations and send them back to clients. Clients then convert those data to user as graphics. Variation of this concept existed, where clients would have some responsibility to process its data before sending it to server to lighten server's burden. This could pose some risk of cheating by users if not handled well.

Since we are creating a simple game, we can let the server handles all computations. The clients only need to retrieve the data and show it to users. Let's start by preparing our Node.js server.

Creating Node.js Server

A Node.js server is just our typical Javascript file (.js) telling what kind of server we should make and its configuration. Let us create a file server.js.

var http = require('http').createServer(server).listen(7228);
var io = require('socket.io').listen(http);

function server(request, response){
    response.writeHead(200, {'content-type': 'plain-text'});
    response.end();
}

An explanation is in order. First, we create a variable http as a reference to our server. In this case, we use require('http') to load http module from Node.js. This module then call createServer(server) using dot-chaining (notice that after require('http') we use dot (.) notation). After that we call again listen(7228). listen is a function owned by Node.js server for listening to a specific port. In this case, port 7228.

createServer(server) is used to create a server, but what does the server inside the function parameter means? It is simply a function name passed into createServer() function. We define it as function server(request, response){ ... } in the code above. Because we use socket.io, Node.js simply work as the middleman, so we only tell it to write http header and wait for data from user through websocket.

We have also created a variable io which refers to our socket.io. We use require('socket.io') and then use dot-chaining to make it listen to our Node.js http server.

We have finished preparing our server. To run it, simply call it in command line using node

node server.js

Utilities

Before we start delving into the core game logic, let us define a few utility functions to help us process game data.

function lenObject(obj){
    var size = 0;
    for (key in obj) { if (obj.hasOwnProperty(key)) size++; }
    return size;
}

function arrayHasOwnIndex(array, prop) {
    return array.hasOwnProperty(prop) && /^0$|^[1-9]\d*$/.test(prop) && prop <= 4294967294; // 2^32 - 2
}

function collisionDetect(boundA, boundB){
    return ! ( 
        (boundA.y + boundA.height < boundB.y) || 
        (boundA.y > boundB.y + boundB.height) || 
        (boundA.x > boundB.x + boundB.width) || 
        (boundA.x + boundA.width < boundB.x)
    );
}

lenObject() is a function we used to count how many properties a custom object has. arrayHasOwnIndex() is used to determine whether a property is an index of an array or not (this is used in for..in loop). Finally, collisionDetect() is used for our game. We will use simple box boundary system.

We also define a variable.

var playerIn = null;

In the long run, this will be used to store player match-ups so when a new player logged into the game, they will be automatically matched with the first available player. Else, they will wait until another player entered the system. For now, we will put a null value inside it.

Game Logic

It is important for us to have a clear depiction of our game. The game we are creating is as follows.

  • A 1 vs 1 shooter game.
  • Players can join anytime. Match-up is determined by first-come-first-serve.
  • Players can shoot at most 1 bullet each 1 seconds so as not to overburden the server.
  • Players moved their character with arrow keys and shoot using mouse clicks. Mouse clicks is used to determine direction of the bullets. Bullets started from the character and then move according to that direction.

We will design the game according to the description above. Let us start by initializing our socket event.

io.sockets.on('connection', function (socket) {

});

Remember that the variable io refers to our socket.io instance. The code above simply said that in the event of connection established, we will do "something", which will be described inside our function(socket). From now on our code will be put inside this closure.

Player Login

First we would determine what happened when our user logged into the game. Socket.io used on function to determine what happened when user sent us data. In this case, when an user sent clientlogin event alongside their identity number (function(id)), we will place them into the player slot variable from before.

    socket.on('clientlogin', function(id){
        if(playerIn === null){
            playerIn = new Array();
        }
        
        var cnew = true;
        for(var idx = 0; idx < playerIn.length; idx++){
            var d = playerIn[idx];
			
            if(lenObject(d.players) < 2){
                d.players[id] = {
                    'x': 50,
                    'y': 200,
                    'w': 50,
                    'h': 69,
                    'health': 100,
                    'speed':70,
                    'type': 2,
                    'boundary': {
                        'x':14,
                        'y':4,
                        'w':24,
                        'h':52
                    },
                    'lastUpdated': null,
                    'lastShot': null
                };
                d.status = 'ready';
                socket.set('playerslot', idx);
                cnew = false;
            }
        }
		
        if(cnew){
            var slot = {
                'players': {},
                'bullets': new Array(),
                'status': 'wait'
            };
			
            slot.players[id] = {
                'x': 500,
                'y': 200,
                'w': 50,
                'h': 69,
                'health':100,
                'speed':70,
                'type': 1,
                'boundary': {
                    'x':19,
                    'y':17,
                    'w':24,
                    'h':52
                },
                'lastUpdated': null,
                'lastShot': null
            };
        
            var idx = playerIn.push(slot);
            socket.set('playerslot', idx-1);
        }
        
        socket.emit('serverloginsuccess', null);
    });

As we can see, clientlogin is the name of the event sent by user. This name is custom-made and can be changed into anything at all. After that, it is followed by a closure containing the data (id). This variable name is also custom-made and can be changed into anything you want.

Inside the closure, first we check whether playerIn is still null. If it is, we define it as an Array object. Then, we put a variable cnew as true. This is simply a flag which will be used to determine whether a slot is available for the user to join in or not. We will refer to it again later.

Next we iterate the entire playerIn array and check each of its content, whether there is an available slot where another user had entered and is waiting for an enemy. We do this using lenObject and count the players custom property. If there is one with less than 2 players (only 1 is inside), we jump in by creating a barebone character and put it into the slot by the user's id. Then we set the slot's status as 'ready' and set cnew as false. We also use socket.set to store non-volatile data for the user's connection containing the slot number. The next time the user need to see its data (which is the entire game session), they only need to open this data and refer to its index in playerIn.

Meanwhile, there is another if using cnew. If it is set to false, then we don't need to make a new slot. If there is no empty slot to join in, though, the user made a new one and push it into playerIn array. We still use socket.set to save the user's slot so they can refer to it later.

Finally we use socket.emit. This is the counterpart of socket.no, by which it is used to send the data to client (user). In this case we sent 'serverloginsuccess' event to user with no data, thus the value null.

Player Match-up

The code above is done when user logged in on the first time, but users rarely done so simultaneously. After an user logged in, there might be some time before another user joined the game. Thus we need another event by which we will send server data to user while they are waiting for a match-up. In our client, after logged in, the user would send request to server for this data all the time until the server said, yes, they have finally found a match-up.

    socket.on('clientattempt', function(id){
        socket.get('playerslot', function(err, idx){
            var slot = playerIn[idx];

            socket.emit('serverplayerstatus', slot);
        });
    });

This code is small and straightforward. The event name is 'clientattempt', and it contains user id. Remember that we used socket.set to store non-volatile data before? We can retrieve it back using socket.get by specifying the name of our data and a closure which either get our data (in this case it is idx) or throw an error (err). This code simply get the player slot and send them back to client.

Remember that the slot have status property (you can see in the login section above). If it is 'wait', the client would keep on waiting. But if it is 'ready', then the match-up is ready. Now the client will stop sending those 'clientattempt' event, but instead asking another one to initiate the game.

    socket.on('clientgame', function(id){
        socket.get('playerslot', function(err, idx){
            var slot = playerIn[idx];

            socket.emit('servergame', slot);
        });
    });

The code is the same as 'clientattempt', just that the name is changed into 'clientgame'. We can see it more clearly when we designed the client code later. For now, just remember that while the code is exactly the same, these two served different purpose for the client.

Game Update

This part is the final and the longest of all. This event is called each time the client want to update the game. In this event, client send input data: which button the player pressed and where mouse clicks are made. Server would calculate these inputs and moved characters and bullets accordingly. Server also calculates bounding boxes, collisions, and the flow of the game. When one of the players' health reached zero, the game ends. Here is the code.

    socket.on('clientupdate', function(data){
        socket.get('playerslot', function(err, idx){
            //count deltatime
            var now = Date.now();
            var then = playerIn[idx].players[data.id].lastUpdated == null ? Date.now() : playerIn[idx].players[data.id].lastUpdated;
            var thenShot = playerIn[idx].players[data.id].lastShot == null ? Date.now() : playerIn[idx].players[data.id].lastShot;
            var delta = (now - then) / 1000;
            var deltaShot = (now - thenShot) / 1000;
        
            //player movement
            var pspeed = playerIn[idx].players[data.id].speed;
            
            if (data.keys.indexOf('up') > -1) {
                playerIn[idx].players[data.id].y -= pspeed * delta;
            }
            if (data.keys.indexOf('down') > -1) {
                playerIn[idx].players[data.id].y += pspeed * delta;
            }
            if (data.keys.indexOf('left') > -1) {
                playerIn[idx].players[data.id].x -= pspeed * delta;
            }
            if (data.keys.indexOf('right') > -1) {
                playerIn[idx].players[data.id].x += pspeed * delta;
            }
            
            //player movement restriction
            if(playerIn[idx].players[data.id].y < 50)
                playerIn[idx].players[data.id].y += pspeed * delta;
            if(playerIn[idx].players[data.id].y + playerIn[idx].players[data.id].h > 400)
                playerIn[idx].players[data.id].y -= pspeed * delta;
            if(playerIn[idx].players[data.id].x < 0)
                playerIn[idx].players[data.id].x += pspeed * delta;
            if(playerIn[idx].players[data.id].x + playerIn[idx].players[data.id].w > 600)
                playerIn[idx].players[data.id].x -= pspeed * delta;
            
            //bullet movement
            for(var i in playerIn[idx].bullets){
                if(arrayHasOwnIndex(playerIn[idx].bullets, i)){
                    if(playerIn[idx].bullets[i].owner == data.id){
                        playerIn[idx].bullets[i].x += playerIn[idx].bullets[i].speedx * delta;
                        playerIn[idx].bullets[i].y += playerIn[idx].bullets[i].speedy * delta;
                        
                        //if outside bound
                        if(
                            playerIn[idx].bullets[i].x < 0 ||
                            playerIn[idx].bullets[i].x > 600 ||
                            playerIn[idx].bullets[i].y < 50 ||
                            playerIn[idx].bullets[i].y > 400
                        ){
                            playerIn[idx].bullets.splice(i, 1);
                        }
                    }
                    else if(
                        collisionDetect({
                            'x': playerIn[idx].players[data.id].x + playerIn[idx].players[data.id].boundary.x,
                            'y': playerIn[idx].players[data.id].y + playerIn[idx].players[data.id].boundary.y,
                            'width': playerIn[idx].players[data.id].boundary.w,
                            'height': playerIn[idx].players[data.id].boundary.h
                        },
                        {
                            'x': playerIn[idx].bullets[i].x - playerIn[idx].bullets[i].radius/2,
                            'y': playerIn[idx].bullets[i].y - playerIn[idx].bullets[i].radius/2,
                            'width': playerIn[idx].bullets[i].radius,
                            'height': playerIn[idx].bullets[i].radius
                        })
                    ){
                        playerIn[idx].players[data.id].health -= playerIn[idx].bullets[i].damage;
                        playerIn[idx].bullets.splice(i, 1);
                    }
                }
            }
            
            //bullet creation
            if(
                data.mouses.x != null &&
                data.mouses.y != null &&
                (playerIn[idx].players[data.id].lastShot == null || deltaShot >= 1)
            ){
                var px = playerIn[idx].players[data.id].x;
                var py = playerIn[idx].players[data.id].y;
                var pw = playerIn[idx].players[data.id].w;
                var ph = playerIn[idx].players[data.id].h;
                
                var lx = data.mouses.x - px;
                var ly = data.mouses.y - py;
                
                var deg = Math.atan2(ly, lx);
                
                playerIn[idx].bullets.push({
                    'owner': data.id,
                    'x': px + pw/2 + 35 * Math.cos(deg),
                    'y': py + ph/2 + 35 * Math.sin(deg),
                    'radius': 5,
                    'speedx': 100 * Math.cos(deg),
                    'speedy': 100 * Math.sin(deg),
                    'damage': 10
                });
                
                playerIn[idx].players[data.id].lastShot = now;
            }
            
            //update deltatime
            playerIn[idx].players[data.id].lastUpdated = now;
            
            //output
            var end = false;
            //check win condition
            for(i in playerIn[idx].players){
                if(end == false && playerIn[idx].players.hasOwnProperty(i)){
                    if(i == data.id && playerIn[idx].players[i].health <= 0){
                        socket.emit('serverend', 'lose');
                        end = true;
                    }
                    else if(i != data.id && playerIn[idx].players[i].health <= 0){
                        socket.emit('serverend', 'win');
                        end = true;
                    }
                }
            }
            
            if(end == false) socket.emit('servergame', playerIn[idx]);
        });
    });

First, we cakculated delta, which is the delay between now and last update. We do this to ensure smooth movement. As we can see in the next lines, player movements depended on delta in calculating how much pixel should the character moved. This ensure that even in laggy connection, player movements remained constant. Where to move the player characters (up, down, etc.) depends also on the keys property sent by client. We also put restriction in player movement such that when reaching certain pixels in vertical and horizontal area, we moved the player backward from its intended direction. The result in game would look as if the player character stopped moving.

Next we move existing bullets. The logic is straightforward. Also, upon reaching certain pixels like player characters, we deleted the bullets using splice. Note that this bullet movement is enclosed in if statement. We only move the bullets if it is the user's owned bullet (achieved by comparing the bullet's owner property with user's id). Else, we do collision checking with the user's character. If the bullet's and the user's boundary box collides, then we drop the user's health by the bullet's damage power. We also delete the bullet using splice. Doing it this way ensured that we only do collision checking if necessary, as well as preventing our own bullets from hurting our player character.

Then we begin bullet creation logic. First we make sure that user had indeed triggered a mouse click and the 1-second delay is cleared. Then we did a few trigonometric calculation to point out by what degrees the bullet would travel starting from the character's then-current coordinate. The bullet is pushed into bullets array, ready to be processed further, and we update the user's lastShot value to now to enforce the 1-second delay rule.

Next, we updated the lastUpdated for our delta calculation. In the last part, we check the victory condition for our game. If any of the character has 0 or less health, then the game ended. Depending on who had it, the server will send 'serverend' event containing either 'win' or 'lose' value.

Finally, if the game has not ended yet, the server send yet another 'servergame' event containing game data for another cycle. This will be repeated until the victory condition is reached.

Summary

This tutorial covers the server part of our game. The next tutorial will cover the client part of the game. For now, you can run the server but not able to see anything. We hope that this tutorial could give you some insight on how a game server works and how websocket can be used for facilitating full-duplex communication between client and server.