Tutorials‎ > ‎

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

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

Introduction

This is the second part of our 2-part tutorial "Creating Multiplayer Web Game Using Websocket, Node.js, and Socket.io". You can see the first 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 Client Concept

Resuming from our last tutorial, we will start building our game client. As we have already touched before, a client's purpose is to retrieve data from the server and show it to users. Users then interact with the client, which interactions will be sent back to the server for more calculations. Thus our client would focus less in game logic and more on user interface and interaction.

Creating the Web Page

Since our game is web-based, we will use HTML page as our media. Our game components would be built using Javascript. The HTML page only serves as a placeholder to hold everything together.

<!DOCTYPE HTML>
<html>
<head>
    <title>Websocket Shooter
    <script type="text/javascript" src="http://localhost:7228/socket.io/socket.io.js"></script>
    <script type="text/javascript" src="jquery-1.10.2.min.js"></script>
    <script type="text/javascript" src="game.js"></script>
    <style type="text/css">
        body{
            margin:0px;
        }

        .gameboard{
            position:absolute;
            width:600px;
            height:400px;
            margin: -200px 0 0 -300px;
            top:50%;
            left:50%;
            background-color:black;
        }
    </style>
</head>
<body>
</body>
</html>

This page is simple, it only loads the necessary scripts. The interesting part is where we load socket.io.js from localhost. This javascript file is not available normally; it is created in runtime by Node.js server using Socket.io. In order to load it correctly, we would need to start the server. Then we only need to connect to the server's URL and call /socket.io/socket.io.js. The rest of the code is self-explanatory.

Creating the Game Script

We shall move further into creating our game.js. This will be our main client script, and it is referenced in our HTML page above.

Let us start with some variables for saving data. These variables would be used throughout the game to keep track of the data from server as well as doing calculations to help us putting user interfaces where it should be. We put keys and mouse to track our user interaction. The former is an Array, while the latter is a custom object because tracking mouse click is different than tracking keyboard click. We also store images in a variable. We will pre-load the images before starting the game so there will be no delay in actual gameplay due to loading the images. There is nothing worse than stopping the game because some resources have not been loaded yet!

//keystrokes buffer
var keys = [];
var mouses = {
    state: 'up',
    x: null,
    y: null
};

//images
var images = {
    files: {},
    list: {
        'white': 'images/white.png',
        'black': 'images/black.png'
    }
}

Utilities

We also declares a few utility functions to ease our game data processing.

function keydown(e){
    keys[e.keyCode] = true;
}

function keyup(e){
    delete keys[e.keyCode];
}

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)
    );
}

function getOffsetX(e){
    e = e || window.event;

    var target = e.target || e.srcElement;
    var rect = target.getBoundingClientRect();
    
    return offsetX = e.clientX - rect.left;
}

function getOffsetY(e){
    e = e || window.event;

    var target = e.target || e.srcElement;
    var rect = target.getBoundingClientRect();

    return e.clientY - rect.top;
}

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

keydown and keyup are functions which will be called when user triggered keyboard clicks. As we can see, when key is "down" (user pressed the key), we put the keycode into our keys array. When the key is "up" (user lifted their finger away from the key), the array value with the keycode as its index will be deleted. This ensures that multiple key presses will be allowed for user.

The other functions had been explained in previous tutorials. getOffsetX and getOffsetY are used to get precise mouse click coordinate relative to our game board (canvas) element. Else we will get mouse click coordinate relative to our HTML page, which in most case would be inaccurate.

Preparing the Game

First let us enclosure our game within jQuery's "ready" event.

$(document).ready(function(){

});

Inside this closure, we begin to prepare our game elements.

    var canvas = $('<canvas width="600" height="400" />')
        .addClass('gameboard')
        .appendTo('body')
        .off('click')
        .off('mousedown')
        .off('mouseup')
        .off('mousemove');
    var ctx = canvas.get(0).getContext('2d');
    
    var board = {
        'width': canvas.width(),
        'height': canvas.height()-50,
        'x': 0,
        'y': 50,
        'bg': canvas.css('background-color')
    };

    var socket = null;
    var id = null;
    var players = null;
    var bullets = {
        'self': null,
        'enemy': null
    };
    
    $.each(images.list, function(idx, res){
        var img = new Image();
        img.src = res;
        img.onload = function(){
            images.files[idx] = this;
            if(Object.keys(images.files).length == Object.keys(images.list).length){
                start();
            }
        }
        img.onerror = function(){
            clearScreen();
            drawText(145, (board.height/2), 24, 'Failed to load game resources');
        }
    });
	
    //screen utilities
    function clearScreen(){
        ctx.beginPath();
        ctx.rect(0, 0, canvas.get(0).width, canvas.get(0).height);
        ctx.fillStyle = '#000000';
        ctx.fill();
        ctx.closePath();
    }

    function drawText(x, y, size, text){
        ctx.fillStyle = 'rgb(255,255,255)';
        ctx.font = size + 'px Arial';
        ctx.textAlign = 'left';
        ctx.textBaseline = 'top';
        ctx.fillText(text, x, y);
    }

The first thing that we do is creating the canvas element (this tutorial used jQuery to create it. You can use other methods as long as it succeeded). Then we take the canvas context (the variable ctx) which we will use to draw shapes and images. The rest of the variables are placeholders for data retrieved from server.

Using jQuery, we loop through the list property of our images variable. We do pre-loading for each filename in the list. If we succeeded in loading the image, we put it into our images' file property - ready to be used anytime by our client without further reloading. If error, though, we stop the process and show message to user using drawText function. When the entire list had been loaded, we call start() function, which we will define later. But before that...

Yep, another utilities. clearScreen is used to re-paint our entire canvas to default background color (in this tutorial, black. You can use any color that you want). Hence, we "clear" the screen. On the other hand, drawText, which we had just used when pre-loading our images, is used to display text to user. It contains configuration for our text and the command to paint it into the canvas. We will use these two a lot in the game, so it is wise to put these into utility functions.

Starting the Game

    //game logic
    function start(){
        clearScreen();
        drawText(140, 20, 36, 'Websocket Shooter');

		//draw button
        ctx.beginPath();
        ctx.rect((board.width/2 - 150/2), 300, 150, 40);
        ctx.fillStyle = '#0088ff';
        ctx.fill();
        ctx.strokeStyle = '#efefef';
        ctx.stroke();
        ctx.fillStyle = 'rgb(1,1,1)';
        ctx.font = 'bold 15px Arial';
        ctx.style = 'bold';
        ctx.textAlign = 'left';
        ctx.textBaseline = 'top';
        ctx.fillText('Connect', (board.width/2 - 150/2) + (150/2 - ((('Connect').length*8)/2)), 300 + (40/2 - 15/2));
        ctx.closePath();
        
        canvas.on('click', function(e){
            if(collisionDetect(
                {'x': getOffsetX(e), 'y': getOffsetY(e), 'width': 0, 'height': 0},
                {'x': (board.width/2 - 150/2), 'y': 300, 'width': 150, 'height': 40}
            )){
                canvas.off('click');
                login();
            }
        });
    }

Let us move forward to the actual game. This function begin by clearing the screen and draw a button. When user clicked the button, they will proceed to log in into the server through login function. Because we drew the button using canvas, we didn't attach an event handler like usual. Rather, we test the collision between the button's boundary and user click.

Player Login

    function login(){
        if(typeof io === 'undefined'){
            clearScreen();
            drawText(135, (board.height/2), 24, 'Failed to connect to game server');
        }
        else{
            socket = io.connect('http://localhost:7228');
            id = Math.floor(Math.random() * 50000000);
            
            socket.emit('clientlogin', id);
            
            socket.on('serverloginsuccess', function(data){
                socket.emit('clientattempt', id);
            });
            
            socket.on('serverplayerstatus', function (data){
                if(data.status == 'wait'){
                    clearScreen();
                    drawText(170, (board.height/2), 24, 'Waiting for other player');
                    window.setTimeout(function(){ socket.emit('clientattempt', id); }, 500);
                }
                else if(data.status == 'ready'){
                    var whitemage = {
                        'x': (board.width/2)-(50/2), 'y': (board.height/2)-(69),
                        'width': 50, 'height': 69,
                        'boundary': {'x':19, 'y':17, 'width':24, 'height':52},
                        'image': images.files.white,
                        'speed': 70                
                    };
                    
                    var blackmage = {
                        'x': (board.width/2)-(50/2), 'y': (board.height/2)-(69),
                        'width': 50, 'height': 69,
                        'boundary': {'x':14, 'y':4, 'width':24, 'height':52},
                        'image': images.files.black,
                        'speed': 70
                    };
                    
                    players = {};
                    
                    if(data.players[id].type == 1){
                        players.self = whitemage;
                        players.enemy = blackmage;
                    }
                    else if(data.players[id].type == 2){
                        players.self = blackmage;
                        players.enemy = whitemage;
                    }
                    
                    clearScreen();
                    ctx.drawImage(players.self.image, players.self.x, players.self.y);
                    drawText(175, (board.height/2) + 12, 24, 'Get ready to rumble!');
                    drawText(90, (board.height/2) + 48, 24, 'Arrow keys to move, mouse click to shoot');
                    
                    window.setTimeout(main, 5000);
                }
            });
        }
    }

This is our login function. First we check whether socket.io.js has been loaded, else show an error message to user. If it is, then we can proceed to connect our client with the server. We put the connection inside a variable socket. Along the way we also generated a random number as our id. This id can be anything that you like. It is only used for assigning player slot in server.

Remember that socket.io used socket.emit to send data and socket.on to tell the code what to do when certain data is retrieved? The client side operates in the same concept. First we will "emit" our id to the server, telling it that we want to login using "clientlogin" event. In the previous tutorial, this event is captured by the server and triggered the slot-assignment process. Then we used socket.on to catch 'serverloginsuccess' event from server, indicating that the login process is successful.

Then we wait for the status from server. If it is still 'wait', then we will display a message to user telling them to wait until a match-up is made. If it is 'ready', though, we begin assigning player character data to user. Next, we put some display to user, showing their assigned player characters, and some information about the gameplay. Finally we set a 5-second delay for the user to ready themselves before the game start. We call main() function in this part.

Main Game Loop

There is a lot of things going inside the main game loop. Basically, we want to get the gameplay data from server, catch user interactions, display every necessary game elements to the user, and loop it again until either victory or defeat is achieved.

    function main(){
        clearScreen();
        document.addEventListener("keydown", keydown, false);
        document.addEventListener("keyup", keyup, false);
        
        socket.emit('clientgame', id);
        
        socket.on('servergame', function(data){
            for(i in data.players){
                if(data.players.hasOwnProperty(i)){
                    if(i == id){
                        players.self.x = data.players[i].x;
                        players.self.y = data.players[i].y;
                        players.self.health = data.players[i].health;
                    }
                    else{
                        players.enemy.x = data.players[i].x;
                        players.enemy.y = data.players[i].y;
                        players.enemy.health = data.players[i].health;
                    }
                }
            }

            bullets.self = [];
            bullets.enemy = [];
            
            for(i in data.bullets){
                if(arrayHasOwnIndex(data.bullets, i)){
                    if(data.bullets[i].owner == id) bullets.self.push(data.bullets[i]);
                    else bullets.enemy.push(data.bullets[i]);
                }
            }
        });
        
        socket.on('serverend', function(data){
            clearScreen();
            window.clearInterval(gameinterval);
            drawText((board.width/2) - 80, (board.height/2) + 12, 24, 'You ' + data + '!');
            //disconnect from game
            socket.disconnect();
        });
        
        canvas.on('mousedown', function(e){
            if(mouses.state == 'up'){
                mouses.state = 'down';
                mouses.x = getOffsetX(e);
                mouses.y = getOffsetY(e);
            }
        });
        canvas.on('mouseup', function(e){
            if(mouses.state == 'down'){
                mouses.state = 'up';
                mouses.x = null;
                mouses.y = null;
            }
        });
        canvas.on('mousemove', function(e){
            if(mouses.state == 'down'){
                mouses.x = getOffsetX(e);
                mouses.y = getOffsetY(e);
            }
        });
    
        var gameinterval = window.setInterval(function(){ update(); render(); }, 1000/24);
    }

First, we activated keyboard event handler so our game starts to capture keyboard click. Then we emit 'clientgame' event to server. If you still remember, this event tells server that the game has been started, and it is okay to start sending data to client now. Our client retrieved the data in 'servergame' event and saved it to its local variables for later use.

We also wait for a 'serverend' event, which tells us that the victory condition has been achieved and we should stop the game loop as well as the connection.

We put soem event handler for our canvas element, which is used to capture mouse click data. This is where getOffsetX and getOffsetY is useful, else we have to do some complicated adjusment again and again.

Finally the loop is started using window.setInterval. We use closure inside it because we want to call 2 functions in particular: update and render. The former is used to send our user interactions data to server while the latter is used to display our user interface to canvas. Notice that I used the interval value of 1000/24 rather than exact value. This means 24 frames per 1 second, of we can say it as 24 fps. You can experiment with this value, but if it is too low (as in, updating too fast), the server might not be able to keep up.

Rendering and Updating

Updating works behind the scene, while rendering is the thing which made users can see the game as something interesting, not a jumbled collection of texts in a black screen.

    function render(){
        clearScreen();
        //draw players
        for(i in players){
            if(players.hasOwnProperty(i)){
                var p = players[i];
                ctx.drawImage(p.image, p.x, p.y);
            }
        }
        //draw bullets - self and enemy
        for(i in bullets.self){
            if(arrayHasOwnIndex(bullets.self, i)){
                ctx.beginPath();
                ctx.arc(bullets.self[i].x, bullets.self[i].y, bullets.self[i].radius, 0, 2 * Math.PI, false);
                ctx.fillStyle = 'yellow';
                ctx.fill();
            }
        }
        for(i in bullets.enemy){
            if(arrayHasOwnIndex(bullets.enemy, i)){
                ctx.beginPath();
                ctx.arc(bullets.enemy[i].x, bullets.enemy[i].y, bullets.enemy[i].radius, 0, 2 * Math.PI, false);
                ctx.fillStyle = 'red';
                ctx.fill();
                ctx.closePath();
            }
        }
        //draw HUD
        ctx.beginPath();
        ctx.moveTo(0,50);
        ctx.lineTo(600,50);
        ctx.fillStyle = 'white';
        ctx.stroke();
        ctx.closePath();
        //draw health
        drawText(50, 1, 20,  'Self');
        drawText(50, 25, 20, 'Enemy');
        drawText(180, 1, 20,  players.self.health);
        drawText(180, 25, 20, players.enemy.health);
    }
    
    function update(){
        var sendKey = new Array();
        
        if (38 in keys) { // Player holding up
            sendKey.push('up');
        }
        if (40 in keys) { // Player holding down
            sendKey.push('down');
        }
        if (37 in keys) { // Player holding left
            sendKey.push('left');
        }
        if (39 in keys) { // Player holding right
            sendKey.push('right');
        }
        
        socket.emit('clientupdate', {
            id: id,
            keys: sendKey,
            mouses: mouses
        });
    }

The render function just...draw everything. It takes all the coordinates and necessary numbers in our placeholder variables and draw it to canvas. As you can see, we used players and bullets variables here. Rather than using it for calculating collision, etc. like in server, we used them instead to determine where to draw which in the canvas.

The update function, on the other hand, only concern itself with our keystrokes variables. As in, keyboard and mouse click. After processing the input, we emit them to server using 'clientupdate' event. If you remember the previous tutorial, this will trigger re-calculation of everything in the server and make it send the updated data back to the client. The client then re-draw everything, send the update again, and the loop continues until 'serverend' is reached as in stated in the main function in previous section.

And that, is the end of our tutorial.

Summary

This tutorial covers the client part of our game. This is the conclusion and the part which allow us to see real result from our codes. 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.

ċ
websocketshootertutorial.zip
(4613k)
Arden Sagiterry Setiawan,
Nov 27, 2013, 9:34 PM