Creating a TypeScript Multiplayer Game
Welcome to part 3 of this Air Traffic Control React Native game blog series. In part 2, create a React Native App with PubNub, we made a React Native app that uses PubNub to subscribe and publish data to our server-side application from part 1, coding a realtime airport application with Node.js. As a recap, the app represents an ATC tower responsible for issuing commands to aircraft, whilst the server-side app represents the airfield and airspace around it.
In this post, we will be introducing PubNub Presence into our app for an enhanced cooperative multiplayer experience. In addition, we will be converting our application into TypeScript and exploring its advantages.
Accompanying code available at https://github.com/lukehuk/pubnub-airport-typescript
Designing a Realtime React Native App with TypeScript
Our ATC app will:
- Be converted into TypeScript
- Introduce a player count powered by PubNub Presence
- Highlight planes that are being selected by other players
To do the conversion we will need the TypeScript Node module and we will need to add some development dependencies to include TypeScript ‘types’. We will also be replacing ESLint with TSLint.
For the multiplayer enhancements, we will be using PubNub Presence. We will combine this with some new Redux actions, reducers and a new React component.
Why Use TypeScript and PubNub?
TypeScript
To understand why we have TypeScript, we need to first look at JavaScript. JavaScript was originally intended for the development of small scripts to be used in a web environment. Nowadays however, JavasScript has increased in popularity considerably. It is used for server-side applications with technologies like Node.js and can be deployed across different platforms with a variety of frameworks and libraries such as React Native. These applications can, unfortunately, become unwieldy to manage due to their typically larger codebases. The code management complexities become particularly apparent when several developers all work on a single project.
Some programming languages, particularly statically typed languages, contain features that can be extremely helpful for code development. Features such as having static type systems, code navigation, error checking before execution and safe refactoring are just a few. TypeScript was created to try and bring those features into the JavaScript environment without making a new language to compete with it. As such, TypeScript was created as a superset to JavaScript and actually compiles into vanilla JavaScript. This means that, despite enriching the development experience, TypeScript code can still run anywhere where JavaScript can.
PubNub Presence
PubNub Presence extends PubNub’s publish/subscribe functionality by providing realtime user state information. When enabled, sister channels are automatically created that carry ‘presence events’. These events allow you to track channel-level and user-level events such as users joining a channel, users leaving a channel and whether custom state information has changed. A common use case for this functionality is in an online chat system. With Presence, it becomes possible to easily identify which users are online/offline and custom state data can be used to monitor additional user state metadata.
Prerequisites for a TypeScript ATC Game
This guide assumes that you have read and followed the setup steps covered in part1, coding a realtime airport application with Node.js, and part 2, create a React Native App with PubNub of this series. To recap, you should have:
- Installed Node.js and checked both Node.js and NPM are installed
- Signed up for PubNub and obtained your free PubNub API keys
- An executable server-side application that will provide the app with plane data
- A React-Native app that provides the game UI and issues plane commands to the server-side application
- An IDE of your choice that supports TypeScript
We will walk through the remaining setup steps for our TypeScript development environment next.
NPM Dependency Changes
Before we can make any code changes, we need to make some package dependency changes. You will need to open a command terminal at the ATC app project directory for the following section.
Additional dependencies
First, we will install a new dependency that makes it easy to generate UUIDs. This will be used to randomly assign users with unique identifiers.
npm install --save uuid
Redundant development dependencies
As we will be using TypeScript and TSLint for our code quality, we no longer need ESLint and associated packages. You can look in your package.json file to see your dependencies (this varies depending on the configuration chosen). In my setup I needed to remove two packages which I achieved with the command below:
npm uninstall --save-dev eslint eslint-config-google eslint-plugin-react
Additional development dependencies
We now need TypeScript and all the types for our dependencies. First, install TypeScript and the type definitions for all our libraries:
npm install --save-dev typescript @types/expo @types/pubnub @types/react @types/react-native @types/react-redux
As mentioned previously, we also want to install TSLint which will be used to keep our code adhering to recommended style guidelines and coding standards. This can be achieved with:
npm install --save-dev tslint tslint-react
JavaScript to TypeScript File Extension Changes
TypeScript uses a different file extension to JavaScript so we need to change all “.js” files to “.ts” files.
In addition, we have React files that use the JSX file extension. We therefore need to change all “.jsx” files to “.tsx” files.
Converting JavaScript and React Code into TypeScript
Configuring the compiler
Converting our React and Javascript files into TypeScript is fairly straightforward, albeit a little tedious. Obviously, the normal approach would be to start with TypeScript from the get-go. If you have not worked with TypeScript before however this is a great exercise to learn the differences and core concepts. Having a compiler to validate our code is a huge advantage and means we can systematically resolve the complaints until we reach our final valid state. So how do we do this?
In order to get the compiler working, we first have to create a “tsconfig.json” file. For reference, I set my JSON to:
{ "compilerOptions": { "allowSyntheticDefaultImports": true, "forceConsistentCasingInFileNames": true, "importHelpers": true, "jsx": "react-native", "lib": ["dom", "esnext"], "target": "esnext", "module": "esnext", "moduleResolution": "node", "noEmit": true, "noFallthroughCasesInSwitch": true, "noImplicitAny": true, "noImplicitReturns": true, "noImplicitThis": true, "noUnusedLocals": true, "noUnusedParameters": true, "strict": true, "skipLibCheck": true, "sourceMap": true } }
If you want to know what these settings mean or want to customize the compiler options further, take a look at the TypeScript handbook.
Any IDE that supports TypeScript should now be able to validate your code in realtime. If however you are limited to a less sophisticated editor, you can validate your code by running tsc
in your terminal at the project root directory.
The main changes you will need to make will be:
- Converting PropTypes into interfaces
- Adding types to parameters and function return types
Converting PropTypes into interfaces
During the creation of our React Components we created PropTypes. PropTypes adds typechecking at runtime which means that if a component is given data of an unexpected type an error will be omitted. TypeScript allows us to do one better – typechecking at compile time. This makes the PropTypes redundant (unless your component receives data from a source outside of the application e.g. an AJAX request but that’s not the case for us).
Let’s take a simple example to demonstrate the PropType conversion from ComHistoryBar
export default class ComHistoryBar extends Component { ... static get propTypes() { return { lastPlaneTransmission: PropTypes.string, lastAtcTransmission: PropTypes.string }; } }
Becomes:
interface IComHistoryBarProps { lastPlaneTransmission: string, lastAtcTransmission: string } export default class ComHistoryBar extends Component<IComHistoryBarProps> { ... }
Creating interfaces
In the example above we are using the primitive type string
. Sometimes our ‘props’ are not primitive types (number, string, boolean, and symbol) and instead require a custom type or interface to further break down the types.
For this project, we will create a “types.ts” file and we will put our custom type definitions into this file. As an example, let’s look at our props interface for GameScreen:
interface IGameScreenStateProps { selectedPlane: string planes: IPlanesData, gameStatus: IGameStatus, }
We can see that “planes” and ”gameStatus” are more complex objects which require child interfaces to fully describe them. These are defined in types.ts. For example, “IGameStatus” is defined as:
export interface IGameStatus { score: number, crashed: boolean }
Comparing this definition to that of the previous PropType of “object” illustrates the additional depth of type-checking information that we are adding to our application with TypeScript.
Linting TypeScript
With our JavaScript project, we used ESLint to ensure our code quality was high and conformed to industry conventions. To achieve the same aims in TypeScript we will be using TSLint.
As we have already added the NPM packages above, we just need to create “tslint.json” file to configure it. This can be automatically generated, but for simplicity you can set the contents to:
{ "defaultSeverity": "error", "extends": ["tslint:recommended", "tslint-react"], "jsRules": {}, "rules": { "no-console": false }, "rulesDirectory": [] }
If you would like to understand this file in greater depth or customize the rules, more information can be found in the TSLint documentation.
After this, your IDE should be able to now validate your code against the linting standards. If your IDE does not support this feature you can always run it via the command-line interface from the project root directory with:
tslint -p .
That said, I strongly advise using an IDE that can resolve all of the TSLint warnings automatically as there will likely be many. Reordering object keys alphabetically, changing single quotes to double quotes and ensuring the correct spacing between parameters are just a few of the things that will be recommended. The motivation behind each rule that TSLint enforces is justified in the TSLint documentation. If however, you want to disable or customize a rule then it is, of course, possible as it was with ESLint.
Including PubNub Presence for Multiplayer Enhancements
Now that we have a type-safe code we can look into adding some new features. Using PubNub Presence we will add the following functionality:
- A status bar with:
- The number of planes flying
- The number of planes landed (score)
- The number of connected players
- Indicators for planes selected by other players
To achieve this we will need to do the following:
- Ensure presence is enabled on Pubnub.com
- Ensure presence is enabled in the subscriber
- Create presence listeners
- Extend redux to support new actions
- Create a status bar
- Pass state to relevant components
Enabling presence on PubNub.com
In order for Presence to work, you need to enable it from the PubNub dashboard. Simply log in, navigate to your API keys and enable the Presence add-on as shown in the screenshot below and then clicking save.
Enabling presence for subscribers
To enable presence on the subscriber we simply need to pass a truthy withPresence
value when we subscribe as shown below:
pubnub.subscribe({ channels: [PLANES_SUB_CHANNEL], withPresence: true, });
Creating presence listeners
In order to trigger the Redux actions, we need to add listeners to the presence events. If a state change event occurs, we need to dispatch an ‘another player has selected a plane’ action. If a user joins the channel we need to dispatch a ‘modify player count’ action. If a user leaves the channel or is unresponsive for the timeout limit, we need to dispatch both a ‘modify player count’ and deselect any planes they may have selected with an ‘another player has selected a plane’ action.
We don’t want to dispatch an ‘another player has selected a plane’ action if the player is the current player. To filter out these events we assign a random UUID to the current player when subscribing, then prevent the action dispatch if that UUID matches the user which the presence event is about.
const subscriberId = uuid.create().toString(); const pubnub = new PubNub({ presenceTimeout: PRESENCE_TIMEOUT, publishKey: config.publishKey, subscribeKey: config.subscribeKey, uuid: subscriberId, });
pubnub.addListener({ message: (message) => { if (message.channel === PLANES_SUB_CHANNEL) { config.dispatch(updatePlanes(message.message)); } else { config.dispatch(newGameEvent(message.message)); } }, presence: (presenceEvent) => { if (presenceEvent.action === "state-change" && presenceEvent.uuid !== subscriberId) { config.dispatch(controllerPlaneSelected({ controllerId: presenceEvent.uuid, controllerPlane: presenceEvent.state.planeSelected, })); } else if (presenceEvent.action === "join") { config.dispatch(controllerCountChange(presenceEvent.occupancy)); } else { config.dispatch(controllerCountChange(presenceEvent.occupancy)); config.dispatch(controllerPlaneSelected({ controllerId: presenceEvent.uuid, controllerPlane: "", })); } }, });
Extend Redux to support new actions
In order to support our additional features, we need to extend our Redux usage by adding two additional actions that trigger state changes for:
- The number of connected players
- Planes being selected by other players
export function controllerCountChange(newCount: number): ActionTypes { return {type: Action.CONTROLLER_COUNT_CHANGED, newCount}; } export function controllerPlaneSelected(controllerDetails: IControllerDetails): ActionTypes { return {type: Action.CONTROLLER_PLANE_SELECTED, controllerDetails}; }
We also need a new reducer to handle the controller (player) related state changes. You can see in the code below we use a switch statement to handle the different actions:
// Reducer to handle a change in the number of players (controllers) and the planes selected by other players function controllers(state: IControllerData = {count: 0, controllerPlanes: {}}, action: ActionTypes): IControllerData { switch (action.type) { case Action.CONTROLLER_PLANE_SELECTED: const controllerPlanes = {...state.controllerPlanes}; if (action.controllerDetails.controllerPlane === "") { delete controllerPlanes[action.controllerDetails.controllerId]; } else { controllerPlanes[action.controllerDetails.controllerId] = action.controllerDetails.controllerPlane; } return {controllerPlanes, count: state.count}; case Action.CONTROLLER_COUNT_CHANGED: return { controllerPlanes: state.controllerPlanes, count: action.newCount, }; default: return state; } }
Create a status bar
We need one additional React component in our project for the game status bar. It simply takes score, player and plane counts via props from its parent, GameScreen, then renders the data. The full TypeScript code for this component is shown below:
import React, {Component} from "react"; import {StyleSheet, Text, View} from "react-native"; interface IGameStatusBarProps { score: number; planeCount: number; controllers: number; } // Renders a status bar that is divided into three sections. Shows the number of planes, players and planes landed. export default class GameStatusBar extends Component<IGameStatusBarProps> { public render() { return ( <View style={styles.container}> <View style={styles.textContainer}> <Text style={styles.text}>Planes: {this.props.planeCount}</Text> </View> <View style={styles.textContainer}> <Text style={styles.text}>Score: {this.props.score}</Text> </View> <View style={styles.textContainer}> <Text style={styles.text}>Controllers: {this.props.controllers}</Text> </View> </View> ); } } const styles = StyleSheet.create({ container: { backgroundColor: "#d02129", borderBottomColor: "#000000", borderBottomWidth: 1, flex: 1, flexDirection: "row", }, text: { color: "#ffffff", textAlign: "center", }, textContainer: { flex: 1, justifyContent: "center", textAlign: "center", }, });
The controller plane data also needs to be passed via props to the React component ‘Plane‘, so we can indicate planes that have been selected by other players. Changing the border color is a good start for this and could be enhanced later.
const planeBorderColour = this.props.controllerSelected ? "#d02129" : "#808080";
Running our Realtime TypeScript App
The application can be started by running expo start
as before. Despite now using TypeScript, we can use Expo in the same way.
part1, coding a realtime airport application with Node.js, and part 2, create a React Native App with PubNub
Refer back to “Setting up the development environment” in part 2, create a React Native App with PubNub to see the different ways to view the app with Expo.
The game should now be running on your chosen device or emulator. Simply start the game server we created in part1, coding a realtime airport application with Node.js, and planes will begin to appear on your screen. To see the multiplayer functionality in action try launching on a second device or open another web browser tab!
Extending our React Native Game
This concludes the ATC game series! To those of you who hadn’t previously had an opportunity to experiment with all the technologies used in this project, hopefully, it has been both interesting and informative. If you are inspired to extend and improve the game, or make something new using PubNub and React Native, feel free to Tweet them to me at @luke_heavens with your mods!
Alternatively, check out some more projects and reads on the PubNub blog!