Codementor Events

Build an Online Multiplayer Game with Socket.io, Redis, & AngularJS

Published Jan 23, 2017
Build an Online Multiplayer Game with Socket.io, Redis, & AngularJS

This tutorial was originally posted by the author on his blog. This version has been edited for clarity and may appear different from the original post.


I've been wanting to write a multiplayer game through socket.io for a while now. In addition to using socket.io add something else to the mix — I thought I'd give Redis a go for the caching layer.

Now, I wasn't aiming to be too ambitious — I just wanted something that would be fun to play and so I opted for checkers.

My folder structure is as follows:

    root
    -- bower_components (for client side modules)
    -- node_modules (for server side modules)
    -- public folder
        checkers.js
        index.html
        style.css
        fonts folder
    redis.js
    app.js

I'll break this down into server-side & client-side, and hopefully, things will join up at the end.

Server-side

I'm a fan of Express and it's something that I'm comfortable with — we'll start by building out the bones of an Express server. In order to do that, we'll install express with npm install express http --save first.

NB: It's --save and not --save-dev. This is a common mistake that's outlined here.

    var express = require('express');
    var app = express();
    var http = require('http');
    var uuid = require('node-uuid'); //used for creating games. 
    ...
    http.listen(3000, function(){
        console.log('http server listening on port 3000');
    });

For the most interesting part, I used the socket.io module from the chat demo. I then installed it with npm install socket.io --save and created a socket variable, which integrated with the HTTP server.

    var socketio = require('socket.io')(http);

I then used the socket.io variable to listen for events and pickup sessionid from the client.

    socketio.on('connection', function(socket){
        console.log('user connected', socket.id);
        socket.on('disconnect', function(){
            console.log('user disconnected');
        }         
    ]);

I also created a dispatcher that I can pass around when it's recreated to send messages out. The dispatcher took three arguments:

  • event
  • message to send
  • the session id

This is what the dispatcher looks like...

    var dispatcher = function(type, obj, toid){
          console.log(obj);
    	    socketio.to(toid).emit(type, obj);
    };

Now that we have the bare server and socket listener set up, we can get our Redis server installed on my linux box with apt-get install redis-server. See this guide for more information.

NB: To start the Redis server locally, use redis-server; to connect to the Redis server for terminal, use redis-cli.

To enable our express server to talk to the Redis server, we installed the Redis module with npm install redis --save and created a new _redis.js file to split off our Redis logic.

    var redis = require('redis');
    var client = redis.createClient();
    client.on('connect',function(){
        console.log('connected to redis');
    });

    var exports = module.exports;

As we split out the different logic into their own specific JS files, it was brought back to our app.js file through var redis = require(./_redis.js); in the app.js file.

After the Redis connection was established, we created a method to create a new game when a user connects.

In the redis.js file, we created method passing in the sessionid and dispatcher to send messages to the client. These will also be exposed to the app.js file using exports.

    exports.createGame = function(sessionid, dispatcher){
        var gamename = 'game';
        client.hmset(
            gamename: {
                player1: sessionid,
                player2: ''
            }
        );
        dispatcher('game', {name: gamename, player: 'player1'}, sessionid);
    });

We use hmset when we're writing the data to the Redis server.

Redis uses key value pairs to keep the data. In this step, we set the key as "game" and used a hash to record the object. Lastly, we called the dispatcher, which sends a "game" event with the gamename along with a message telling the player that he or she is player1 (this is useful later on) to the connected socket id.

The concept of the checkers game is that the first user, also known as player1, will create the game and when the second user is connected, he or she will be player 2.

When a new connection is made, we have to check if there are any games that do not have a player2 assigned to it. We've created a new function in redis.js to handle this: redis.checkAwaitingGames(socket, associateGame, dispatcher);

    exports.checkAwaitingGames = function(socket, callback, dispatcher){
        ...
    }

To check whether or not there is a game waiting for a second player, we used the client object again to loop through the current keys.

    var foundGame = false; // we'll use this to exit if a game is found
    client.keys('*', function(err, games){
        // '*' is a wild card to return all the keys.

        // games is an array so check its length.
        if(games.length > 0){

        }
        else {
            callback({game: '', found: false, socket: socket});
        }
    }

If we don't find any available games, we'd call the associateGame to create another game. If we do find available games, we'd call associateGame to assign the game to player2 as well. (We will define associateGame later in the article.)

If we do have a list of games, we can loop over the list. As Redis stores the key value pairs, we would use hgetall along with the key game to return the value and test to see if there are any player2 sessionid. If we find one, we would tell associateGame that we have a game and automatically send a message to player1.

    games.forEach(function(game, i){
        if(!foundGame){
            client.hgetall(game, function(err, reply){
                if(reply.player2 === '' && reply.player1 !== socket.id){
                    callback({game: game, found: true, socket: socket});
                    foundGame = true;
                    dispatcher('player2 found', {player: socket.id}, reply.player1);
                }
            });
        }
    });

We have spoken about associateGame without defining it. It is a handler defined in app.js to create or assign a game that is done by checker_redis.js.

    var associateGame = function(obj){
        if(obj.found){
            //console.log('associateGame assignGame');
            redis.assignGame(obj.game, obj.socket.id, dispatcher);
        }
        else{
            //console.log('associateGame createGame');
            redis.createGame(obj.socket.id, dispatcher);
        }
    };

As you can imagine, assignGame & createGame will be writing stuff to Redis using hgetall & hmset. It will then send a message to the connecting user with dispatcher.

    exports.assignGame = function(game, socketid, dispatcher){
        client.hgetall(game, function(err, reply){
            if(err){
                exports.createGame(socketid, dispatcher);
            }
            else{
                reply.player2 = socketid;
                client.hmset(game, reply);

                var message = {
                    name: game,
                    player: 'player2'
                }
                dispatcher('game', message, socketid);
            }
        });
    };

The first part of assignGame will return the value for the key that was passed, but if there is an error, it will return then call createGame instead. We would update the value with the player2 sessionid and then update the key with hmset. Lastly, a message will be sent to the client regarding game details and his or her status as player2.

createGame is much simpler. In this case, we know we don't have any available games open. We use createGame to add new games. I used uuid to ensure that I do not try to add a game with the same key name and pass in a new hash along with the uuid.

The next step is to create a message to the connecting user and send the game details to the client, informing him or her that he or she is player1.

    exports.createGame = function(sessionid, dispatcher){
        var uuid1 = uuid.v4();
        client.hmset(uuid1, {
            'player1': sessionid,
            'player2': ''
        });

        var message = {
            name: game,
            player: 'player1'
        };

        dispatcher('game',message, sessionid);
    }

So far we've been able to connect to the server, search for a game, join it if there isn't a player2, and create a new game. Now we're going to use handler when a move is actually made. For this, we'll require to listen out for another type of socket message in our app.js file, "move taken."

    socket.on('move taken', function(obj){
    console.log('move taken', obj);
    redis.getOpponent(obj, dispatcher);
    
  });

So here, a user has taken a move and the client has sent the move taken message along with an object specifying the move. The server will simply send this on to the opponent through redis.getOpponent handler and pass it to the dispatcher for sending messages.

The message will contain the game key and the status of the client, which is either player1 or player2. We use exports so that the getOpponent is available when redis.js is instantiated.

    client.hgetall(obj.name, function(err, found){
    
    if(obj.player ==='player1'){
      dispatcher('move taken', obj, found.player2);
      
    }
    else{
      dispatcher('move taken', obj, found.player1);
    }
  });

The client is called, along with hgetall, to find the key value pair so the correct session will receive the message.

To inform the opposition that one of their pieces has been taken, we used a similar handler called sendTakenPiece. The socket will be listening for "taken pieces".

  client.hgetall(obj.name, function(err, found){
    if(obj.player ==='player1'){
      dispatcher('taken piece', obj, found.player2);
    }
    else{
      dispatcher('taken piece', obj, found.player1);
    }
  });

Finally, in order to keep Redis in the lightest state possible, when player1 disconnects, I will delete the game. Socket.io will be listening for "disconnect" and will call redis.closeGame.

Ideally, we would know which game we are deleting; however, disconnect will not give us that information. Instead, it will give us the sessionid of the disconnecting client. In closeGame, we'll be looping over the games that we have and checking whether the game.player1 is the client's sessionid.

    client.keys('*', function(err, games){
    games.forEach(function(game, i){

      client.hgetall(game, function(err, reply){

        if(reply.player1 === sessionid){
          client.del(game);
        }

        if(reply.player2 ===sessionid){
          dispatcher('player offline', {player: sessionid}, reply.player1);
        }
        
      });
    });
  });

Just to keep a bit of control, we'll only delete the game when player1 disconnects.

That's it! You have a server that will handle messages from the checkers' client-server code.

Client-side

The Client side is simply made up of three files:

  • Checkers.js
  • index.html
  • style.css

This is what the HTML for the board layout looks like:

multiplayer online game

    <table id="chess_board" cellpadding="0" cellspacing="0">

            <tr>

                <td id="E1">
                    <i ng-if="checkPosition('A1')===false" ng-click = "movehere('A1')" ng-class="getPositionClass('A1')">&nbsp;</i>
                    <i ng-if="checkPosition('A1')===true" ng-click = "pieceselected('A1')" ng-class="getPositionClass('A1')"></i>
                </td>
                <td id="E2">
                    <i ng-if="checkPosition('A2')===false" ng-click = "movehere('A2')" ng-class="getPositionClass('A2')">&nbsp;</i>
                    <i ng-if="checkPosition('A2')===true" ng-click = "pieceselected('A2')" ng-class="getPositionClass('A2')"></i>
                </td>
                <td id="F3">
                    <i ng-if="checkPosition('A3')===false" ng-click = "movehere('A3')" ng-class="getPositionClass('A3')">&nbsp;</i>
                    <i ng-if="checkPosition('A3')===true" ng-click = "pieceselected('A3')" ng-class="getPositionClass('A3')"></i>
                </td>
                <td id="E4">
                    <i ng-if="checkPosition('A4')===false" ng-click = "movehere('A4')" ng-class="getPositionClass('A4')">&nbsp;</i>
                    <i ng-if="checkPosition('A4')===true" ng-click = "pieceselected('A4')" ng-class="getPositionClass('A4')"></i>
                </td>
            </tr>
            
            ...

There will be 6 tr's and 4 td's. Just to keep things simple, I decided to a predetermined the table size, create an array to hold each square on the board called pieces, and predefine where the player pieces are already situated.

    vm.pieces = 
      [
        {position: 'A1', class: vm.icons.player2, player: 'player2', queen: false},
        {position: 'A2', class: vm.icons.blank, player: undefined, queen: false},
        {position: 'A3', class: vm.icons.player2, player: 'player2', queen: false},
        {position: 'A4', class: vm.icons.blank, player: undefined, queen: false},...

Each piece is given (initially) a class, player, and queen value — all of these values can be changed as the game proceeds. The class is the icon that is displayed and a scope method — $scope.getPositionClass — is used to return the class to query the array.

    $scope.getPositionClass = function(position){
      return _.where(vm.pieces, {position: position})[0].class;
    };

The player refers to the client or the opponent's piece at that grid reference while the Queen refers to the piece can move both directions on the board.

The ngClass is used to display the checker icons. You'll notice that I adopted the controller as approach. I used <body ng-app="checkers" ng-controller="checkerctrl as vm"> and var vm = this; so that the scope shadowing wouldn't be an issue (using ngIf creates a separate scope).

I used checkPosition to check if the position on the grid already has a checker piece on it.

    $scope.checkPosition = function(position){
      if(_.where(vm.pieces, { position: position })[0].player === undefined){
        return false;
      }
      return true;
    };

To start the socket connection, we used `var socket = io();, which opens the socket connection with the server. The first function we created was used to handle creating a game on connection.

        socket.on('game', function(game){
      vm.game = game;
      if(vm.game.player==='player1'){
        //vm.myturn = true;
      }
      $scope.$apply();
    });

Socket looks for an event of game when the user first connects and sets the local variable of the game details to the game passed in the event. The $scope.$apply() is used to start the $digest cycle so the bindings are updated. player 2 found waits for the server to tell it that a second player has joined the game so that it can begin. vm.myturn ensures that no moves can be made without another player being present and to prevent cheating when it's the other player's turn:

        socket.on('player2 found', function(game){
      vm.myturn = true;
      vm.player2Found = true;
      $scope.$apply();
    });

If the opponent quits the game, we'll handle it with:

        socket.on('player offline', function(msg){
      if(vm.game.player ==='player1'){
        vm.player2Found = false;
      }

      vm.myturn = false;
      $scope.$apply();
    });

Since the game is player1-driven, it only makes a difference to the system if player 2 quits the game (if you recall, we delete the game from the Redis dashboard if player 1 quits). We then set vm.myturn to false to ensure that no moves can be made.

Moving onto something more interesting: with the help of ngIf, the board is split into grids that have pieces on the grid and those without. When a player clicks on a piece in order to move it, this piece and its grid reference will be recorded in $scope.pieceselected.

    $scope.pieceselected=function(position){	
      vm.validMoves = [];
      vm.selectedpiece = _.where(vm.pieces, { position: position })[0];
      vm.GetValidMoves();
    };

Here, we used lodash to search through the array in order to find the correct item. Once it's been found, it will be stored in vm.selectedpiece for future usage. As a side note, I can not recommend lodash enough if you are using arrays to hold data and query data.

As a part of the piece selection, vm.GetValidMoves() is called to build a list of valid moves that a piece could make. Since both players start by moving in opposite directions, there must be a seperate way of building the list.

    vm.GetValidMoves = function(){
        var index = rows.indexOf(vm.selectedpiece.position.split('')[0]);
        var colindex = parseInt(vm.selectedpiece.position.split('')[1]);
        if(vm.selectedpiece.player==='player2' || vm.selectedpiece.queen ===true){
            if(index < rows.indexOf('F')){
                var Row = rows[index + 1];
            ...
            }
        }
        if(vm.selectedpiece.player==='player1' || vm.selectedpiece.queen ===true){
            if(index > rows.indexOf('A')){
                var Row = rows[index - 1];
            ...
            }
        }
    }
```javascript

The row and the column are then passed to another method  `vm.checkDiagonal(Row + (colindex + 1));` to ensure that the position is not already occupied and added the position to the array `vm.validMoves`.

When a player makes a move, Angular will call `$scope.movehere` passing in the position in the checkers grid that the players piece is moving to.

```javascript
    $scope.movehere=function(position){
    ....
    }

A lot of this section is made up of validating the move id and making sure that the correct player is moving. If it's not, then return false.

        if(!vm.validateMove(vm.selectedpiece.position, position))
        {
          return false;
        }
        vm.myturn = false;
        
        
        
        
        vm._movedTo = _.where(vm.pieces, {position: position})[0];
        vm._movedTo.queen = vm.selectedpiece.queen;
        
        if(vm.game.player ==='player1'){
          vm._movedTo.class=vm.selectedpiece.class;

          if(!vm._movedTo.queen){
            var atAway = vm.player2Home.indexOf(position);
            
            if(atAway >= 0){
              vm._movedTo.queen = true;
              vm._movedTo.class=vm.icons.player1Queen;
            }
          }
        }
        else{
          vm._movedTo.class=vm.selectedpiece.class;
          if(!vm._movedTo.queen){
            var atAway = vm.player1Home.indexOf(position);
            if(atAway >= 0){
              vm._movedTo.queen = true;
              vm._movedTo.class=vm.icons.player2Queen;
            }
          }
        }
        vm._movedTo.player=vm.game.player;
        vm.selectedpiece.class= vm.icons.blank;
        vm.selectedpiece.player=undefined;
        vm.selectedpiece.queen = false;
    vm._movedTo, player: vm.game.player, name: vm.game.name});
        socket.emit('move taken', {piece: vm.selectedpiece, movedTo: vm._movedTo, player: vm.game.player, name: vm.game.name});
      }

The last thing to do is to send the move message to the server.

Wrapping up

Whoop! That was the longest blog to date but I hope you found it interesting!

The git repository is here and should be available for you to fork.

If you're interested in building a multi-user app using socket.io, check out these two posts: Build a Multi-user App using Socket.io (Part 1): Lightweight Chat App and Build a Multi-user App using Socket.io (Part 2): Creating a Matchmaking Game Server

Discover and read more posts from Dominic Scanlan
get started
post commentsBe the first to share your opinion
Didier Palacios
6 years ago

Hi have a question: how can I test the program? How should the folders be placed? Thanks

Show more replies