In this tutorial, learn how to build a realtime Angular game from scratch, taking advantage of the awesome realtime Pusher capabilities.
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:
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.
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.
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 }
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 })
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.
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.
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 }
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.
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.
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.
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.
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.
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.
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.