Build a battleship game with Angular
A basic understanding of TypeScript and JavaScript is needed to follow this tutorial.
Angular is a great framework for building modern JavaScript applications. In this article, we will be building a simple Battleship game with Angular and will make use of the realtime capabilities of Pusher Channels 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:
The code for the completed app can be found on GitHub.
Setting up the application with Angular CLI
First, we will install Angular to our game app with Angular CLI.
To install Angular CLI:
npm install -g angular-cli
Now we can install Angular to our app (named ng-battleship
):
ng 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:
cd ng-battleship
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:
npm 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.
We can now include these libraries in angular-cli.json
, in the styles
and scripts
keys, so they can be loaded for our app:
{
// ...
"apps": [
{
// ...
"styles": [
// ...
"../node_modules/bulma/css/bulma.css",
"../node_modules/ng2-toastr/bundles/ng2-toastr.min.css"
],
"scripts": [
"../node_modules/pusher-js/dist/web/pusher.min.js"
],
// ...
],
// ...
}
Configuring Toastr
We need to add the following lines to app.module.ts
so we can use Toastr:
// ./src/app/app.module.ts
import { ToastModule } from 'ng2-toastr/ng2-toastr';
@NgModule({
// ...
imports: [
// ...
ToastModule.forRoot()
],
// ...
})
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:
ng generate class Player
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:
// ./src/app/player.ts
export class Player {
id: number;
score: number = 0;
constructor(values: Object = {}) {
Object.assign(this, values);
}
}
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:
let player = Player({
id: 1,
score: 15
})
Now we can update the Board
class with the appropriate attributes and constructor logic, as we did in the Player
class:
// ./src/app/board.ts
import { Player } from './player'
export class Board {
player: Player;
tiles: Object[];
constructor(values: Object = {}) {
Object.assign(this, values);
}
}
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 being 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:
ng 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:
// ./src/app/board.service.ts
import { Injectable } from '@angular/core';
@Injectable()
export class BoardService {
constructor() { }
}
Now we can update the service with the logic needed for working with the boards:
// ./src/app/board.service.ts
import { Injectable } from '@angular/core';
import { Board } from './board'
import { Player } from './player'
@Injectable()
export class BoardService {
playerId: number = 1;
boards: Board[] = [];
constructor() { }
// method for creating a board which takes
// an optional size parameter that defaults to 5
createBoard(size:number = 5) : BoardService {
// create tiles for board
let tiles = [];
for(let i=0; i < size; i++) {
tiles[i] = [];
for(let j=0; j< size; j++) {
tiles[i][j] = { used: false, value: 0, status: '' };
}
}
// generate random ships for the board
for (let i = 0; i < size * 2; i++) {
tiles = this.randomShips(tiles, size);
}
// create board
let board = new Board({
player: new Player({ id: this.playerId++ }),
tiles: tiles
});
// append created board to `boards` property
this.boards.push(board);
return this;
}
// function to return the tiles after a value
// of 1 (a ship) is inserted into a random tile
// in the array of tiles
randomShips(tiles: Object[], len: number) : Object[] {
len = len - 1;
let ranRow = this.getRandomInt(0, len),
ranCol = this.getRandomInt(0, len);
if (tiles[ranRow][ranCol].value == 1) {
return this.randomShips(tiles, len);
} else {
tiles[ranRow][ranCol].value = 1;
return tiles;
}
}
// helper function to return a random
// integer between ${min} and ${max}
getRandomInt(min, max) {
return Math.floor(Math.random() * (max - min + 1)) + min;
}
// returns all created boards
getBoards() : Board[] {
return this.boards;
}
}
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:
<!-- ./src/app/app.component.html -->
<div class="section">
<div class="container">
<div class="content">
<h1 class="title">Ready to sink some battleships?</h1>
<h6 class="subtitle is-6"><strong>Pusher Battleship</strong></h6>
<hr>
<!-- shows when a player has won the game -->
<section *ngIf="winner" class="notification is-success has-text-centered" style="color:white">
<h1>Player {{ winner.player.id }} has won the game!</h1>
<h5>Click <a href="{{ gameUrl }}">here</a> to start a new game.</h5>
</section>
<!-- shows while waiting for 2nd user to join -->
<div *ngIf="players < 2">
<h2>Waiting for 2nd user to join...</h2>
<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>
</div>
<!-- loops through the boards array and displays the player and board tiles -->
<div class="columns" *ngIf="validPlayer">
<div class="column has-text-centered" *ngFor="let board of boards; let i = index">
<h5>
PLAYER {{ board.player.id }} <span class="tag is-info" *ngIf="i == player">You</span>
// <strong>SCORE: {{ board.player.score }}</strong>
</h5>
<table class="is-bordered" [style.opacity] = "i == player ? 0.5 : 1">
<tr *ngFor="let row of board.tiles; let j = index">
<td *ngFor="let col of row; let k = index"
(click) = "fireTorpedo($event)"
[style.background-color] = "col.used ? '' : 'transparent'"
[class.win] = "col.status == 'win'" [class.fail] = "col.status !== 'win'"
class="battleship-tile" id="t{{i}}{{j}}{{k}}">
{{ col.value == "X" ? "X" : "💀" }}
</td>
</tr>
</table>
</div>
</div>
<div class="has-text-centered">
<span class="tag is-warning" *ngIf="canPlay">Your turn!</span>
<span class="tag is-danger" *ngIf="!canPlay">Other player's turn.</span>
<h5 class="title"><small>{{ players }} player(s) in game</small></h5>
</div>
</div>
</div>
</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:
/* ./src/styles.css */
.container {
padding: 50px;
}
.battleship-tile {
color: black;
}
.win {
background-color: #23d160;
color: #fff;
}
.fail {
background-color: #ff3860;
color: #fff;
}
.content table td, .content table th {
border: 1px solid #dbdbdb;
padding: 0.5em 0.75em;
vertical-align: middle;
height: 50px;
text-align: center;
}
.content table {
width: 80%;
margin: 0 auto;
}
.content table tr:hover {
background-color: transparent;
}
.battleship-tile:hover {
cursor: pointer;
}
Game functionality
Next, we will update our main app component with some logic for the game:
// ./src/app/app.component.ts
// import needed classes and services
import { Component, ViewContainerRef } from '@angular/core';
import { ToastsManager } from 'ng2-toastr/ng2-toastr';
import { BoardService } from './board.service'
import { Board } from './board'
// set game constants
const NUM_PLAYERS: number = 2;
const BOARD_SIZE: number = 6;
@Component({
selector: 'app-root',
templateUrl: './app.component.html',
styleUrls: ['./app.component.css'],
providers: [BoardService]
})
export class AppComponent {
canPlay: boolean = true;
player: number = 0;
players: number = 0;
gameId: string;
gameUrl: string = location.protocol + '//' + location.hostname + (location.port ? ':' + location.port: '');
constructor(
private toastr: ToastsManager,
private _vcr: ViewContainerRef,
private boardService: BoardService
) {
this.toastr.setRootViewContainerRef(_vcr);
this.createBoards();
}
// event handler for click event on
// each tile - fires torpedo at selected tile
fireTorpedo(e:any) : AppComponent {
let id = e.target.id,
boardId = id.substring(1,2),
row = id.substring(2,3), col = id.substring(3,4),
tile = this.boards[boardId].tiles[row][col];
if (!this.checkValidHit(boardId, tile)) {
return;
}
if (tile.value == 1) {
this.toastr.success("You got this.", "HURRAAA! YOU SANK A SHIP!");
this.boards[boardId].tiles[row][col].status = 'win';
this.boards[this.player].player.score++;
} else {
this.toastr.info("Keep trying.", "OOPS! YOU MISSED THIS TIME");
this.boards[boardId].tiles[row][col].status = 'fail'
}
this.canPlay = false;
this.boards[boardId].tiles[row][col].used = true;
this.boards[boardId].tiles[row][col].value = "X";
return this;
}
checkValidHit(boardId: number, tile: any) : boolean {
if (boardId == this.player) {
this.toastr.error("Don't commit suicide.", "You can't hit your own board.")
return false;
}
if (this.winner) {
this.toastr.error("Game is over");
return false;
}
if (!this.canPlay) {
this.toastr.error("A bit too eager.", "It's not your turn to play.");
return false;
}
if(tile.value == "X") {
this.toastr.error("Don't waste your torpedos.", "You already shot here.");
return false;
}
return true;
}
createBoards() : AppComponent {
for (let i = 0; i < NUM_PLAYERS; i++)
this.boardService.createBoard(BOARD_SIZE);
return this;
}
// winner property to determine if a user has won the game.
// once a user gets a score higher than the size of the game
// board, he has won.
get winner () : Board {
return this.boards.find(board => board.player.score >= BOARD_SIZE);
}
// get all boards and assign to boards property
get boards () : Board[] {
return this.boardService.getBoards()
}
}
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 Channels
What is Pusher Channels?
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 a Channels app in your 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:
npm install -S express body-parser pusher
Creating the server file in the app root folder:
touch server.js
Updating with the required logic:
// ./server.js
const express = require('express');
const bodyParser = require('body-parser');
const path = require('path');
const Pusher = require('pusher');
const crypto = require("crypto");
const app = express();
app.use(bodyParser.json());
app.use(bodyParser.urlencoded({ extended: false }));
// initialise Pusher.
// Replace with your credentials from the Pusher Dashboard
const pusher = new Pusher({
appId: 'YOUR_APP_ID',
key: 'YOUR_APP_KEY',
secret: 'YOUR_APP_SECRET',
cluster: 'YOUR_APP_CLUSTER',
encrypted: true
});
// to serve our JavaScript, CSS and index.html
app.use(express.static('./dist/'));
// CORS
app.all('/*', function(req, res, next) {
res.header("Access-Control-Allow-Origin", "*");
res.header("Access-Control-Allow-Headers", "*");
next();
});
// endpoint for authenticating client
app.post('/pusher/auth', function(req, res) {
let socketId = req.body.socket_id;
let channel = req.body.channel_name;
let presenceData = {
user_id: crypto.randomBytes(16).toString("hex")
};
let auth = pusher.authenticate(socketId, channel, presenceData);
res.send(auth);
});
// direct all other requests to the built app view
app.get('*', (req, res) => {
res.sendFile(path.join(__dirname, './dist/index.html'));
});
// start server
var port = process.env.PORT || 3000;
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 Channels, we then serve the app index.html
file for every other request. See the Channels ‘authenticating users’ guide for more information on the auth process.
Initialising Pusher and listening for changes
As a last step, we will initialise Channels and listen for changes in our game:
// ./src/app/app.component.ts
// declare Pusher const for use
declare const Pusher: any;
export class AppComponent {
pusherChannel: any;
//...
constructor(
private toastr: ToastsManager,
private _vcr: ViewContainerRef,
private boardService: BoardService,
) {
//...
this.initPusher();
this.listenForChanges();
}
// initialise Pusher and bind to presence channel
initPusher() : AppComponent {
// findOrCreate unique channel ID
let id = this.getQueryParam('id');
if (!id) {
id = this.getUniqueId();
location.search = location.search ? '&id=' + id : 'id=' + id;
}
this.gameId = id;
// init pusher
const pusher = new Pusher('PUSHER_APP_KEY', {
authEndpoint: '/pusher/auth',
cluster: 'eu'
});
// bind to relevant Pusher presence channel
this.pusherChannel = pusher.subscribe(this.gameId);
this.pusherChannel.bind('pusher:member_added', member => { this.players++ })
this.pusherChannel.bind('pusher:subscription_succeeded', members => {
this.players = members.count;
this.setPlayer(this.players);
this.toastr.success("Success", 'Connected!');
})
this.pusherChannel.bind('pusher:member_removed', member => { this.players-- });
return this;
}
// Listen for `client-fire` events.
// Update the board object and other properties when
// event triggered
listenForChanges() : AppComponent {
this.pusherChannel.bind('client-fire', (obj) => {
this.canPlay = !this.canPlay;
this.boards[obj.boardId] = obj.board;
this.boards[obj.player].player.score = obj.score;
});
return this;
}
// initialise player and set turn
setPlayer(players:number = 0) : AppComponent {
this.player = players - 1;
if (players == 1) {
this.canPlay = true;
} else if (players == 2) {
this.canPlay = false;
}
return this;
}
fireTorpedo(e:any) : AppComponent {
// ...
// trigger `client-fire` event once a torpedo
// is successfully fired
this.pusherChannel.trigger('client-fire', {
player: this.player,
score: this.boards[this.player].player.score,
boardId: boardId,
board: this.boards[boardId]
});
return this;
}
// helper function to get a query param
getQueryParam(name) {
var match = RegExp('[?&]' + name + '=([^&]*)').exec(window.location.search);
return match && decodeURIComponent(match[1].replace(/\+/g, ' '));
}
// helper function to create a unique presence channel
// name for each game
getUniqueId () {
return 'presence-' + Math.random().toString(36).substr(2, 8);
}
// check if player is a valid player for the game
get validPlayer(): boolean {
return (this.players >= NUM_PLAYERS) && (this.player < NUM_PLAYERS);
}
// ...
}
The initPusher()
function initialises 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:
ng build
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!
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.
25 August 2017
by Olayinka Omole