Building a multiplayer game with Angular and Pusher

building-multiplayer-game-angular-pusher-header.png

In this tutorial, learn how to build a realtime Angular game from scratch, taking advantage of the awesome realtime Pusher capabilities.

Introduction

Angular is a great framework for building modern JavaScript applications. In this article, we will be building a simple Angular game, Battleship game and will make use of the realtime capabilities of Pusher to enable two players to play against each other.

In this article, you will learn how to:

  • Start an Angular app from scratch with Angular CLI
  • Generate and make use of Angular classes and services
  • Create a game view using the main App Component
  • Connect an Angular app to Pusher and trigger and listen for realtime events.

To follow along properly, you’ll need some knowledge of TypeScript. I will also be using the ES6 syntax. We will keep things really simple and at the end of the tutorial, we will have learned how to work with both Angular and Pusher to build modern realtime JavaScript applications.

The final app will look like this:

Battleship game built with Angular

The code for the completed app can be found on GitHub, and a live demo can be found here.

Setting up the application with Angular CLI

First, we will install Angular to our game app with Angular CLI.

To install Angular CLI:

1npm install -g angular-cli

Now we can install Angular to our app (named ng-battleship):

1ng new ng-battleship

You can now navigate to the new directory and start the app on an Angular development server to verify that everything works properly:

1cd ng-battleship
2    ng serve

You can view the app on http://localhost:4200/. This includes live-reload support, so when a source file changes, your browser automatically reloads the application. This means that you don’t have to restart the app when making changes and adding features during development. Pretty neat.

Importing external libraries

We will be using a couple of libraries to make our development smoother. We can pull them in with npm:

1npm install -S pusher-js bulma ng2-toastr

The Pusher JavaScript library is pulled in for interacting with the Pusher service. We also optionally pull in Bulma, my CSS framework of choice, to take advantage of some quick styles. Ng2-toaster is used to display pretty notifications.

We can now include these libraries in angular-cli.json, in the styles and scripts keys, so they can be loaded for our app:

1{
2      // ...
3      "apps": [
4        {
5          // ...
6          "styles": [
7            // ...
8            "../node_modules/bulma/css/bulma.css",
9            "../node_modules/ng2-toastr/bundles/ng2-toastr.min.css"
10          ],
11          "scripts": [
12            "../node_modules/pusher-js/dist/web/pusher.min.js"
13          ],
14          // ...
15      ],
16      // ...
17    }

Configuring Toastr

We need to add the following lines to app.module.ts so we can use Toastr:

1// ./src/app/app.module.ts
2
3    import { ToastModule } from 'ng2-toastr/ng2-toastr';
4
5    @NgModule({
6      // ...
7      imports: [
8        // ...
9        ToastModule.forRoot()
10      ],
11      // ...
12    })

Creating the player boards

Generating player and board classes

Angular creates TypeScript files so we can use classes to represent players and the boards for each player. Using Angular CLI to generate the player and board classes:

1ng generate class Player
2    ng generate class Board

We can now update the logic for both classes. We will update our Player class first, as the Board class will depend on it:

1// ./src/app/player.ts
2
3    export class Player {
4      id: number;
5      score: number = 0;
6
7      constructor(values: Object = {}) {
8        Object.assign(this, values);
9      }
10    }

In the Player class, we specify two properties: the player id and score. The id is a unique identifier for the player, while the score property holds the values of the player’s score in the game (both properties have a type of number). We also provide constructor logic that will allow us to create an instance of the class like this:

1let player = Player({
2      id: 1,
3      score: 15
4    })

Now we can update the Board class with the appropriate attributes and constructor logic, as we did in the Player class:

1// ./src/app/board.ts
2
3    import { Player } from './player'
4    export class Board {
5      player: Player;
6      tiles: Object[];
7
8      constructor(values: Object = {}) {
9        Object.assign(this, values);
10      }
11    }

The Board class has two attributes: the player attribute which is an instance of the Player class, and tiles which is an array of objects making up the tiles in the board, each tile is represented by an object.

Now we have separate testable entities for both players and boards and we can begin to build our game’s functionality around these entities.

Creating the board service

Next, we will create a service to manage the core operations for our board:

1ng generate service Board

This generates the service and a corresponding unit test file in the src/app directory. The generated service will look like this:

1// ./src/app/board.service.ts
2
3    import { Injectable } from '@angular/core';
4
5    @Injectable()
6    export class BoardService {
7      constructor() { }
8    }

Now we can update the service with the logic needed for working with the boards:

1// ./src/app/board.service.ts
2
3    import { Injectable } from '@angular/core';
4    import { Board } from './board'
5    import { Player } from './player'
6
7    @Injectable()
8    export class BoardService {
9
10      playerId: number = 1;
11      boards: Board[] = [];
12
13      constructor() { }
14
15      // method for creating a board which takes
16      // an optional size parameter that defaults to 5
17      createBoard(size:number = 5) : BoardService {
18        // create tiles for board
19        let tiles = [];
20        for(let i=0; i < size; i++) {
21          tiles[i] = [];
22          for(let j=0; j< size; j++) {
23            tiles[i][j] = { used: false, value: 0, status: '' };
24          }
25        }
26        // generate random ships for the board
27        for (let i = 0; i < size * 2; i++) {
28          tiles = this.randomShips(tiles, size);
29        }
30        // create board
31        let board = new Board({
32          player: new Player({ id: this.playerId++ }),
33          tiles: tiles
34        });
35        // append created board to `boards` property
36        this.boards.push(board);
37        return this;
38      }
39
40      // function to return the tiles after a value
41      // of 1 (a ship) is inserted into a random tile  
42      // in the array of tiles
43      randomShips(tiles: Object[], len: number) : Object[] {
44        len = len - 1;
45        let ranRow = this.getRandomInt(0, len),
46            ranCol = this.getRandomInt(0, len);
47        if (tiles[ranRow][ranCol].value == 1) {
48          return this.randomShips(tiles, len);
49        } else {
50          tiles[ranRow][ranCol].value = 1;
51          return tiles;
52        }
53      }
54
55      // helper function to return a random
56      // integer between ${min} and ${max}
57      getRandomInt(min, max) {
58        return Math.floor(Math.random() * (max - min + 1)) + min;
59      }
60
61      // returns all created boards
62      getBoards() : Board[] {
63        return this.boards;
64      }
65    }

The service contains various methods which help us create and use our battleship boards. The createBoard() method creates a board for each player. We can specify the size of the board by passing in the size parameter when calling the method. The default size of the board is specified as 5.

We also define the randomShips() and getRandomInt() methods to help in creating the boards. The randomShips() method is used to assign ships (represented by a value of 1) randomly to the board. The getRandomInt() is a helper method used to generate a random integer value between the specified min and max values — ideally, this can be moved to a helper service.

Finally, the getBoards() method returns all the boards created by the service.

Now that we have created the service to interact with our boards, we can go ahead with building the core game functionality.

Adding core game functionality and views

Adding the game view

We will start by updating our app view. We will add some HTML and CSS, with some Angular directives to display the player boards:

1<!-- ./src/app/app.component.html -->
2    <div class="section">
3      <div class="container">
4        <div class="content">
5          <h1 class="title">Ready to sink some battleships?</h1>
6          <h6 class="subtitle is-6"><strong>Pusher Battleship</strong></h6>
7          <hr>
8
9          <!-- shows when a player has won the game -->
10          <section *ngIf="winner" class="notification is-success has-text-centered" style="color:white">
11            <h1>Player {{ winner.player.id }} has won the game!</h1>
12            <h5>Click <a href="{{ gameUrl }}">here</a> to start a new game.</h5>
13          </section>
14
15          <!-- shows while waiting for 2nd user to join -->
16          <div *ngIf="players < 2">
17            <h2>Waiting for 2nd user to join...</h2>
18            <h3 class="subtitle is-6">You can invite them with this link: {{ gameUrl }}?id={{ gameId }}. You can also open <a href="{{ gameUrl }}?id={{ gameId }}" target="_blank">this link</a> in a new browser, to play all by yourself.</h3>
19          </div>
20
21          <!-- loops through the boards array and displays the player and board tiles -->
22          <div class="columns" *ngIf="validPlayer">
23            <div class="column has-text-centered" *ngFor="let board of boards; let i = index">
24              <h5>
25                PLAYER {{ board.player.id }} <span class="tag is-info" *ngIf="i == player">You</span>
26                // <strong>SCORE: {{ board.player.score }}</strong>
27              </h5>
28              <table class="is-bordered" [style.opacity] = "i == player ? 0.5 : 1">
29                <tr *ngFor="let row of board.tiles; let j = index">
30                  <td *ngFor="let col of row; let k = index"
31                  (click) = "fireTorpedo($event)"
32                  [style.background-color] = "col.used ? '' : 'transparent'"
33                  [class.win] = "col.status == 'win'" [class.fail] = "col.status !== 'win'"
34                  class="battleship-tile" id="t{{i}}{{j}}{{k}}">
35                  {{ col.value == "X" ? "X" : "?" }}
36                </td>
37              </tr>
38            </table>
39          </div>
40        </div>
41
42        <div class="has-text-centered">
43          <span class="tag is-warning" *ngIf="canPlay">Your turn!</span>
44          <span class="tag is-danger" *ngIf="!canPlay">Other player's turn.</span>
45          <h5 class="title"><small>{{ players }} player(s) in game</small></h5>
46        </div>
47
48      </div>
49    </div>
50    </div>

Take note of the *ngFor directive which we use to loop through the boards for each player to display the board’s tiles and player properties. We also specify a fireTorpedo() event handler for click events on each tile.

Don’t worry, most of the properties referred to in the code above have not been defined yet. We will define them below as we add more functionality to our game.

Note: You should check out the official Angular guide to have a better understanding of its template syntax.

Adding some optional styles:

1/* ./src/styles.css */
2
3    .container {
4      padding: 50px;
5    }
6    .battleship-tile {
7      color: black;
8    }
9    .win {
10      background-color: #23d160;
11      color: #fff;
12    }
13    .fail {
14      background-color: #ff3860;
15      color: #fff;
16    }
17    .content table td, .content table th {
18        border: 1px solid #dbdbdb;
19        padding: 0.5em 0.75em;
20        vertical-align: middle;
21        height: 50px;
22        text-align: center;
23    }
24    .content table {
25      width: 80%;
26      margin: 0 auto;
27    }
28    .content table tr:hover {
29        background-color: transparent;
30    }
31    .battleship-tile:hover {
32      cursor: pointer;
33    }

Game functionality

Next, we will update our main app component with some logic for the game:

1// ./src/app/app.component.ts
2
3    // import needed classes and services
4    import { Component, ViewContainerRef } from '@angular/core';
5    import { ToastsManager } from 'ng2-toastr/ng2-toastr';
6    import { BoardService } from './board.service'
7    import { Board } from './board'
8
9    // set game constants
10    const NUM_PLAYERS: number = 2;
11    const BOARD_SIZE: number = 6;
12
13    @Component({
14      selector: 'app-root',
15      templateUrl: './app.component.html',
16      styleUrls: ['./app.component.css'],
17      providers: [BoardService]
18    })
19
20    export class AppComponent {
21      canPlay: boolean = true;
22      player: number = 0;
23      players: number = 0;
24      gameId: string;
25      gameUrl: string = location.protocol + '//' + location.hostname + (location.port ? ':' + location.port: '');
26
27      constructor(
28        private toastr: ToastsManager,
29        private _vcr: ViewContainerRef,
30        private boardService: BoardService
31      ) {
32        this.toastr.setRootViewContainerRef(_vcr);
33        this.createBoards();
34      }
35
36      // event handler for click event on
37      // each tile - fires torpedo at selected tile
38      fireTorpedo(e:any) : AppComponent {
39        let id = e.target.id,
40          boardId = id.substring(1,2),
41          row = id.substring(2,3), col = id.substring(3,4),
42          tile = this.boards[boardId].tiles[row][col];
43        if (!this.checkValidHit(boardId, tile)) {
44          return;
45        }
46
47        if (tile.value == 1) {
48          this.toastr.success("You got this.", "HURRAAA! YOU SANK A SHIP!");
49          this.boards[boardId].tiles[row][col].status = 'win';
50          this.boards[this.player].player.score++;
51        } else {
52          this.toastr.info("Keep trying.", "OOPS! YOU MISSED THIS TIME");
53          this.boards[boardId].tiles[row][col].status = 'fail'
54        }
55        this.canPlay = false;
56        this.boards[boardId].tiles[row][col].used = true;
57        this.boards[boardId].tiles[row][col].value = "X";
58        return this;
59      }
60
61      checkValidHit(boardId: number, tile: any) : boolean {
62        if (boardId == this.player) {
63          this.toastr.error("Don't commit suicide.", "You can't hit your own board.")
64          return false;
65        }
66        if (this.winner) {
67          this.toastr.error("Game is over");
68          return false;
69        }
70        if (!this.canPlay) {
71          this.toastr.error("A bit too eager.", "It's not your turn to play.");
72          return false;
73        }
74        if(tile.value == "X") {
75          this.toastr.error("Don't waste your torpedos.", "You already shot here.");
76          return false;
77        }
78        return true;
79      }
80
81      createBoards() : AppComponent {
82        for (let i = 0; i < NUM_PLAYERS; i++)
83          this.boardService.createBoard(BOARD_SIZE);
84        return this;
85      }
86
87      // winner property to determine if a user has won the game.
88      // once a user gets a score higher than the size of the game
89      // board, he has won.
90      get winner () : Board {
91        return this.boards.find(board => board.player.score >= BOARD_SIZE);
92      }
93
94      // get all boards and assign to boards property
95      get boards () : Board[] {
96        return this.boardService.getBoards()
97      }
98    }

In the code above, first, we import the required objects and declare the game constants. Then, we initialize the component and define some of the properties and functions needed for the game view.

The createBoards() function creates boards for the game using the board service, based on the number of users and the game board size defined by the NUM_PLAYERS and BOARD_SIZE constants respectively.

The fireTorpedo() function handles every click event on any tile in the game view. It checks if a hit is valid first, using the checkValidHit() function, then determines if it was a hit or miss, and provides feedback to the user.

Adding multiplayer functionality using Pusher

What is Pusher?

Pusher is a service that makes it very easy to add realtime functionality to mobile and web applications. We will be making use of it to provide realtime updates between the two players in our game.

Pusher setup

Head over to Pusher and register for a free account, if you don’t already have one. Then create an app on the dashboard, and copy out the app credentials (App ID, Key, Secret and Cluster). It is super straight-forward.

You also need to enable client events in the Pusher dashboard for the app you created. This is super important, as we will be using client events for communication.

Creating the game backend

To make use of presence channels in Pusher, we will be need to implement a backend (in our case, we will use Node.js). This is as a result of presence channels needing authentication. There are other types of channels in Pusher (Public, Private) — You can read more about them here.

First, we pull in the required packages for our server:

1npm install -S express body-parser pusher

Creating the server file in the app root folder:

1touch server.js

Updating with the required logic:

1// ./server.js
2
3    const express = require('express');
4    const bodyParser = require('body-parser');
5    const path = require('path');
6    const Pusher = require('pusher');
7    const crypto = require("crypto");
8
9    const app = express();
10    app.use(bodyParser.json());
11    app.use(bodyParser.urlencoded({ extended: false }));
12
13    // initialise Pusher.
14    // Replace with your credentials from the Pusher Dashboard
15    const pusher = new Pusher({
16      appId: 'YOUR_APP_ID',
17      key: 'YOUR_APP_KEY',
18      secret: 'YOUR_APP_SECRET',
19      cluster: 'YOUR_APP_CLUSTER',
20      encrypted: true
21    });
22
23    // to serve our JavaScript, CSS and index.html
24    app.use(express.static('./dist/'));
25
26    // CORS
27    app.all('/*', function(req, res, next) {
28      res.header("Access-Control-Allow-Origin", "*");
29      res.header("Access-Control-Allow-Headers", "*");
30      next();
31    });
32
33    // endpoint for authenticating client
34    app.post('/pusher/auth', function(req, res) {
35      let socketId = req.body.socket_id;
36      let channel = req.body.channel_name;
37      let presenceData = {
38        user_id: crypto.randomBytes(16).toString("hex")
39      };
40      let auth = pusher.authenticate(socketId, channel, presenceData);
41      res.send(auth);
42    });
43
44    // direct all other requests to the built app view
45    app.get('*', (req, res) => {
46      res.sendFile(path.join(__dirname, './dist/index.html'));
47    });
48
49    // start server
50    var port = process.env.PORT || 3000;
51    app.listen(port, () => console.log('Listening at http://localhost:3000'));

In the code above, we simply define an endpoint (/pusher/auth) for authenticating clients with Pusher, we then serve the app index.html file for every other request. See the Pusher ‘authenticating users’ guide for more information on the auth process.

Initialising Pusher and listening for changes

As the last step, we will initialize Pusher and listen for changes in our game:

1// ./src/app/app.component.ts
2
3    // declare Pusher const for use
4    declare const Pusher: any;
5
6    export class AppComponent {
7      pusherChannel: any;
8      //...
9
10      constructor(
11        private toastr: ToastsManager,
12        private _vcr: ViewContainerRef,
13        private boardService: BoardService,
14      ) {
15        //...
16        this.initPusher();
17        this.listenForChanges();
18      }
19
20      // initialise Pusher and bind to presence channel
21      initPusher() : AppComponent {
22        // findOrCreate unique channel ID
23        let id = this.getQueryParam('id');
24        if (!id) {
25          id = this.getUniqueId();
26          location.search = location.search ? '&id=' + id : 'id=' + id;
27        }
28        this.gameId = id;
29
30        // init pusher
31        const pusher = new Pusher('PUSHER_APP_KEY', {
32          authEndpoint: '/pusher/auth',
33          cluster: 'eu'
34        });
35
36        // bind to relevant Pusher presence channel
37        this.pusherChannel = pusher.subscribe(this.gameId);
38        this.pusherChannel.bind('pusher:member_added', member => { this.players++ })
39        this.pusherChannel.bind('pusher:subscription_succeeded', members => {
40          this.players = members.count;
41          this.setPlayer(this.players);
42          this.toastr.success("Success", 'Connected!');
43        })
44        this.pusherChannel.bind('pusher:member_removed', member => { this.players-- });
45
46        return this;
47      }
48
49      // Listen for `client-fire` events.
50      // Update the board object and other properties when 
51      // event triggered
52      listenForChanges() : AppComponent {
53        this.pusherChannel.bind('client-fire', (obj) => {
54          this.canPlay = !this.canPlay;
55          this.boards[obj.boardId] = obj.board;
56          this.boards[obj.player].player.score = obj.score;
57        });
58        return this;
59      }
60
61      // initialise player and set turn
62      setPlayer(players:number = 0) : AppComponent {
63        this.player = players - 1;
64        if (players == 1) {
65          this.canPlay = true;
66        } else if (players == 2) {
67          this.canPlay = false;
68        }
69        return this;
70      }
71
72      fireTorpedo(e:any) : AppComponent {
73        // ...
74
75        // trigger `client-fire` event once a torpedo
76        // is successfully fired
77        this.pusherChannel.trigger('client-fire', {
78          player: this.player,
79          score: this.boards[this.player].player.score,
80          boardId: boardId,
81          board: this.boards[boardId]
82        });
83        return this;
84      }
85
86      // helper function to get a query param
87      getQueryParam(name) {
88        var match = RegExp('[?&]' + name + '=([^&]*)').exec(window.location.search);
89        return match && decodeURIComponent(match[1].replace(/\+/g, ' '));
90      }
91
92      // helper function to create a unique presence channel
93      // name for each game
94      getUniqueId () {
95        return 'presence-' + Math.random().toString(36).substr(2, 8);
96      }
97
98      // check if player is a valid player for the game
99      get validPlayer(): boolean {
100        return (this.players >= NUM_PLAYERS) && (this.player < NUM_PLAYERS);
101      }
102
103      // ...
104    }

The initPusher() function initializes Pusher on the client side and subscribes to the presence channel created by the getUniqueId() method. We also make use of some functionality provided by the Pusher presence channel (member_added, subscription_succeeded and member_removed events) to update the players count and set turns.

The listenForChanges() is used to listen for the client-fire client event, and update the game once it is triggered. The client-fire event is triggered in the fireTorpedo() function once a torpedo has been fired successfully. The event is broadcast with some data which will be used when updating the game view. The syntax for triggering an event with Pusher is channelObject.``trigger(eventName, data) — You can read more about it here.

Generating static files and starting the game

Finally, we can build the app and start the server for the game:

1ng build
2    node server.js

Angular CLI generates the static files to the ./dist folder and we can view the game on http://localhost:8000 once the server starts!

The live demo is hosted on Heroku.

Conclusion

In this tutorial, we have learned how to build a realtime Angular app from scratch, taking advantage of the awesome realtime capabilities of Pusher. There are a lot of improvements that could be made to the base game — the entire code for it is hosted on Github, you’re welcome to make contributions and ask questions.

Do you have any other great use cases for Angular and Pusher? Let us know in the comments.