Creating a realtime game with React Native
You will need an Android emulator for testing, and the Android SDK. You should have a basic knowledge of React Native.
In this tutorial, we’ll be implementing the classic game Tic-Tac-Toe with React Native and Pusher. This tutorial assumes that you already have a basic knowledge of React Native.
Prerequisites
- Pusher Account - a Pusher account is needed to create a Pusher instance that we will be using in this tutorial. If you don’t already have an account, you can sign up here. After creating an account, you can go ahead and create a new Pusher app.
- Android SDK - we’ll be specifically deploying the app as an Android App so you need the Android SDK to run the app on an Android device or emulator.
- A Machine that’s ready for React Native Development - if you don’t already have your machine set up for React Native, you can follow the Getting Started Guide on the official docs. Be sure to follow the instructions in the "Building Projects with Native Code” tab.
- Genymotion or Android Emulator - this is optional, as you can always use a real device for testing.
What we’re going to build
Here’s what the app is going to look like by default:
When a user has chosen to create a room, the room ID will be generated by the app. This room ID should be entered by another user so the game can begin. Throughout this tutorial, I’ll be referring to the user other than the current user as the “rival”.
Once someone has joined the room, the Tic-Tac-Toe board will be shown. At this point, any of the players can start the first move.
Once the last move is used to fill the board, an alert will be shown to the room creator asking whether they want to restart the game (empty the board and start over) or end the game. If the room creator has chosen to end the game, the app state will reset and the default screen will be shown.
You can find the full source code of the app in its Github repo.
Coding the server component
The server component authenticates the requests that will come from the app. This is needed because we’ll be using client events to send data from client to client. The server component authenticates the request whenever an app tries to connect using the API key of the app you created earlier. This way you can verify if the request really came from your app.
Start by initializing a package.json
file:
npm init
Install the dependencies:
npm install --save express body-parser pusher dotenv
Create a .env
file in the same folder as the package.json
file and add your Pusher app details:
APP_ID="YOUR PUSHER APP ID"
APP_KEY="YOUR PUSHER APP KEY"
APP_SECRET="YOUR PUSHER APP SECRET"
APP_CLUSTER="YOUR PUSHER APP CLUSTER"
Create a server.js
file and add the following code:
var express = require('express'); // for running a server
var bodyParser = require('body-parser'); // for processing JSON submitted in the request body
var Pusher = require('pusher'); // for connecting to Pusher
require('dotenv').config();
var app = express();
app.use(bodyParser.json()); // for parsing JSON strings passed in the request body
app.use(bodyParser.urlencoded({ extended: false })); // for parsing URL encoded request body
var pusher = new Pusher({ // connect to pusher
appId: process.env.APP_ID, // load the Pusher app settings from the .env file
key: process.env.APP_KEY,
secret: process.env.APP_SECRET,
cluster: process.env.APP_CLUSTER,
});
app.get('/', function(req, res){ // for testing if the server is running
res.send('everything is good...');
});
app.post('/pusher/auth', function(req, res) { // authenticate user's who's trying to connect
var socketId = req.body.socket_id;
var channel = req.body.channel_name;
var auth = pusher.authenticate(socketId, channel);
res.send(auth);
});
var port = process.env.PORT || 5000;
app.listen(port);
Here’s what the code above does:
- Line 1 imports
express
, a web framework for Node.js which allows us to create a server and respond to specific routes. - Line 2 imports
body-parser
, a middleware for parsing the request body so that the data passed in the request body can be accessed like an object. For example, in the/pusher/auth
route, this allows us to do the following to access the socket ID from the request body:req.body.socket_id
. - Line 3 imports the
pusher
package. This allows us to communicate with the Pusher app you created earlier in order to authenticate the user (line 25). - Line 5 imports the
dotenv
package which loads up the config in the.env
file you created earlier. You can see them being accessed as environment variables on lines 12 to 15. - Lines 7 to 9 tells Express to use
body-parser
to create two different middleware entries, one for parsing JSON strings and the other for parsing URL encoded strings. Theextended
option is set tofalse
because we’re not really expecting rich objects and arrays to be included in the request body. Instead, we’re only expecting plain JSON strings to be passed in the request body. - Lines 18 to 20 are for testing if the server is running, you can access
http://localhost:5000
from your browser. If you see the string output “everything is good…” then it works. - Lines 22 to 27 are for processing the authentication requests coming from the app. The authentication request is sent every time a client connects to Pusher from the app that we’ll be creating. Note that the code for authenticating users doesn’t really have any security measures in place. This means anyone can just use your Pusher app if they happen to get a hold of your Pusher app credentials.
Coding the app
Now we’re ready to add the code for the app. First bootstrap a new React Native app:
react-native init RNPusherTicTacToe
Once it’s done, you can now install the dependencies:
npm install --save lodash.range pusher-js react-native-prompt shortid react-native-spinkit@latest
Out of these dependencies, React Native Spinkit has some assets which need to be linked, so execute the following command to link those:
react-native link
Here’s how the packages you’ve just installed are used in the app:
- pusher-js - for using Pusher. This allows us to send messages to channels, and receive messages from channels in real-time.
- react-native-prompt - for showing a prompt box, used for getting user input.
- react-native-spinkit - for showing a spinner while waiting for another player to join the room.
- lodash.range - for generating arrays which has a specific number of items.
- shortid - for generating unique IDs when creating a room.
Now we’re ready to add the code for the app. First, open the index.android.js
file and replace the default code with the following:
import React, { Component } from 'react';
import {
AppRegistry
} from 'react-native';
import Main from './components/Main';
export default class RNPusherTicTacToe extends Component {
render() {
return (
<Main />
);
}
}
AppRegistry.registerComponent('RNPusherTicTacToe', () => RNPusherTicTacToe);
Make sure that RNPusherTicTacToe
matches the name you’ve given to the app when you created it with react-native init
.
Next, create a components/Main.js
file and add the following:
import React, { Component } from 'react';
import {
StyleSheet,
Text,
View,
Button,
Alert
} from 'react-native';
// include the dependencies
import Pusher from 'pusher-js/react-native';
import shortid from 'shortid';
import Spinner from 'react-native-spinkit';
// include the components
import Header from './Header';
import Home from './Home'; // the default screen
import Board from './Board'; // the tic-tac-toe board and score UI
Inside the constructor, initialize the state and the functions that will be used throughout the component:
export default class Main extends Component {
constructor() {
super();
this.state = {
username: '', // the name of the user
piece: '', // the piece assigned to the user
rival_username: '', // the name of the rival player
is_playing: false, // whether the user is currently playing or not
show_prompt: false, // whether the prompt box for entering the room name is visible
is_waiting: false, // whether the user is currently waiting for another player (rival) or not
is_room_creator: false // whether the user is the room's creator
}
this.game_channel = null; // the Pusher channel where data regarding the game will be sent
this.is_channel_binded = false; // whether a channel has already been binded or not
this.onChangeUsername = this.onChangeUsername.bind(this); // executes when the value of the username text field changes
this.onPressCreateRoom = this.onPressCreateRoom.bind(this); // executes when user creates a room
this.onPressJoinRoom = this.onPressJoinRoom.bind(this); // executes when user taps on the join room button
this.joinRoom = this.joinRoom.bind(this); // the function for joining a room
this.onCancelJoinRoom = this.onCancelJoinRoom.bind(this); // executes when user cancels joining a room
this.endGame = this.endGame.bind(this); // the function for ending the game
}
}
Before the component is mounted, connect to Pusher using the credentials you’ve been given when you created the Pusher app:
componentWillMount() {
this.pusher = new Pusher('YOUR PUSHER API KEY', {
authEndpoint: 'YOUR AUTH ENDPOINT',
cluster: 'YOUR PUSHER APP CLUSTER',
encrypted: true
});
}
When the component is updated, we need to check whether the user is already waiting for a rival and that a Pusher channel has not been bound to any events yet. If that’s the case, we listen for the client-joined
event. When this happens, update the state so that the UI shows the game board. If the user is the room creator, trigger the same event so that the rival (the one who joined the room) is informed that the game can already start.
componentDidUpdate() {
if(this.state.is_waiting && !this.is_channel_binded){
this.game_channel.bind('client-joined', (data) => {
this.setState({
is_waiting: false,
is_playing: true,
rival_username: data.username
});
if(this.state.is_room_creator){
// inform the one who joined the room that the game can begin
this.game_channel.trigger('client-joined', {
username: this.state.username // send the name of the room creator to the one who joined
});
}
});
this.is_channel_binded = true;
}
}
In the render
method, the Home
component is shown by default. It displays the UI for letting the user enter their name, and either join or create a new room. Once a rival joins a room, the game board will be shown. The Spinner
component is used as the transition state between the two while waiting for a rival to join a room.
render() {
return (
<View style={styles.container}>
<Header title={"RN Pusher Tic-Tac-Toe"} />
<Spinner
style={styles.spinner}
isVisible={this.state.is_waiting}
size={75}
type={"WanderingCubes"}
color={"#549eff"}
/>
{
!this.state.is_playing && !this.state.is_waiting &&
<Home
username={this.state.name}
onChangeUsername={this.onChangeUsername}
onPressCreateRoom={this.onPressCreateRoom}
onPressJoinRoom={this.onPressJoinRoom}
show_prompt={this.state.show_prompt}
onCancelJoinRoom={this.onCancelJoinRoom}
/>
}
{
this.state.is_playing &&
<Board
channel={this.game_channel}
username={this.state.username}
piece={this.state.piece}
rival_username={this.state.rival_username}
is_room_creator={this.state.is_room_creator}
endGame={this.endGame}
/>
}
</View>
);
}
Here’s the function that’s executed when the text field for entering the user’s name changes:
onChangeUsername(username) {
this.setState({username});
}
When a user taps on the Create Room button, generate a unique ID for the room and subscribe to a new Pusher channel using that ID. Here we’re using a private channel so that we can send messages directly from the app:
onPressCreateRoom() {
let room_id = shortid.generate(); // generate a unique ID for the room
this.game_channel = this.pusher.subscribe('private-' + room_id); // subscribe to a channel
// alert the user of the ID that the friend needs to enter
Alert.alert(
'Share this room ID to your friend',
room_id,
[
{text: 'Done'},
],
{ cancelable: false }
);
// show loading state while waiting for someone to join the room
this.setState({
piece: 'X', // room creator is always X
is_waiting: true,
is_room_creator: true
});
}
When a rival taps on the Join Room button, the prompt box is shown:
onPressJoinRoom() {
this.setState({
show_prompt: true
});
}
Once the rival joins the room, the following function is executed. The room_id
is provided by the prompt box so we simply use it to subscribe to the same channel as the room creator. This allows the two users to communicate directly using this channel. Note that the code below doesn’t handle if a third person happens to join the room. You can add the functionality to check for the number of users in the room if you want. That way the app will reject it if there are already two users in the room.
joinRoom(room_id) {
this.game_channel = this.pusher.subscribe('private-' + room_id);
// inform the room creator that a rival has joined
this.game_channel.trigger('client-joined', {
username: this.state.username
});
this.setState({
piece: 'O', // the one who joins the room is always O
show_prompt: false,
is_waiting: true // wait for the room creator to confirm
});
}
When the user cancels joining of a room, simply hide the prompt box:
onCancelJoinRoom() {
this.setState({
show_prompt: false
});
}
When the room creator decides to end the game, the app is reset back to its default state:
endGame() {
// reset to the default state
this.setState({
username: '',
piece: '',
rival_username: '',
is_playing: false,
show_prompt: false,
is_waiting: false,
is_room_creator: false
});
// reset the game channel
this.game_channel = null;
this.is_channel_binded = false;
}
Lastly, add the styles:
const styles = StyleSheet.create({
container: {
flex: 1,
padding: 20,
backgroundColor: '#F5FCFF',
},
spinner: {
flex: 1,
alignSelf: 'center',
marginTop: 20,
marginBottom: 50
}
});
Next is the Header
component. Create a components/Header.js
file and add the following:
import React, { Component } from 'react';
import {
StyleSheet,
Text,
View
} from 'react-native';
export default class Header extends Component {
render() {
return (
<View style={styles.title_container}>
<Text style={styles.title}>{this.props.title}</Text>
</View>
);
}
}
const styles = StyleSheet.create({
title_container: {
flex: 1,
},
title: {
alignSelf: 'center',
fontWeight: 'bold',
fontSize: 30
}
});
All this component does is to display the title of the app in the header.
Next, create a components/Home.js
file. As mentioned earlier, this is the default component that is shown the first time the user opens the app or when the room creator ends the game.
import React, { Component } from 'react';
import {
StyleSheet,
Text,
View,
TextInput,
Button
} from 'react-native';
import Prompt from 'react-native-prompt';
export default class Home extends Component {
render() {
return (
<View style={styles.content_container}>
<View style={styles.input_container}>
<TextInput
style={styles.text_input}
onChangeText={this.props.onChangeUsername}
placeholder={"What's your name?"}
maxLength={20}
value={this.props.username}
/>
</View>
<View style={styles.button_container}>
<Button
onPress={this.props.onPressCreateRoom}
title="Create Room"
color="#4c87ea"
style={styles.button}
/>
<Button
onPress={this.props.onPressJoinRoom}
title="Join Room"
color="#1C1C1C"
style={styles.button}
/>
</View>
<Prompt
title="Enter Room Name"
visible={this.props.show_prompt}
onSubmit={this.props.joinRoom}
onCancel={this.props.onCancelJoinRoom}
/>
</View>
);
}
}
const styles = StyleSheet.create({
content_container: {
flex: 1
},
input_container: {
marginBottom: 20
},
button_container: {
flexDirection: 'row',
justifyContent: 'space-around',
alignItems: 'center'
},
text_input: {
backgroundColor: '#FFF',
height: 40,
borderColor: '#CCC',
borderWidth: 1
},
button: {
flex: 1
}
});
Next, create a components/Board.js
file. This component serves as the main meat of the app because it’s where the game happens.
First, include the components and packages that we’ll be needing:
import React, { Component } from 'react';
import {
StyleSheet,
Text,
View,
TextInput,
Button,
TouchableHighlight,
Alert
} from 'react-native';
import range from 'lodash.range';
In the constructor, bind the methods for generating the content for the board (3x3 board). The possible combinations for getting a score are also declared. The ids
are used as the IDs for referring to the individual blocks. As you can see, it’s an array which has three arrays in it. Each of these arrays pertains to the rows in the board, and its items pertains to the individual blocks. So when referring to the second column in the first row of the board, you can get the ID for that by using this.ids[0][1]
. This will then return 1
. The ID will be used later on to determine the scores based on the possible_combinations
array.
export default class Board extends Component {
constructor() {
super();
this.generateRows = this.generateRows.bind(this); // bind the method for generating the rows for the board
this.generateBlocks = this.generateBlocks.bind(this); // bind the method for generating individual blocks for each row
// the possible combinations for getting a score in a 3x3 tic-tac-toe board
this.possible_combinations = [
[0, 3, 6],
[1, 4, 7],
[0, 1, 2],
[3, 4, 5],
[2, 5, 8],
[6, 7, 8],
[0, 4, 8],
[2, 4, 6]
];
// the IDs of the individual blocks
this.ids = [
[0, 1, 2],
[3, 4, 5],
[6, 7, 8]
];
// the individual rows
this.rows = [
range(3).fill(''), // make an array with 3 elements and set each item to an empty string
range(3).fill(''),
range(3).fill('')
];
this.state = {
moves: range(9).fill(''), // the pieces (X or O) used on each block
x_score: 0, // score of the room creator
o_score: 0 // score of the rival
}
}
Right below the declaration for this.ids
is the array which will be used to generate the rows in the board.
Once the component is mounted, we then want to listen for the client-make-move
event to happen. This event is triggered every time a user places their piece (either “X” or “O”) on the board. Note that this will only be triggered on the rival and not the user who has sent the event.
componentDidMount() {
this.props.channel.bind('client-make-move', (data) => {
let moves = this.state.moves;
let id = this.ids[data.row_index][data.index]; // get the ID based on the row index and block index
moves[id] = data.piece; // set the piece
// update the UI
this.setState({
moves
});
this.updateScores.call(this, moves); // update the user scores
});
}
Every time a move is made, the updateScores
function is executed. This loops through all the possible combinations. It uses the every()
method to check whether a specific piece was used on each of the items for a possible combination. For example, if “X” is used for blocks 0, 1, and 2, then 1 point is rewarded to the user who has “X” as their piece.
updateScores(moves) {
var pieces = {
'X': 0,
'O': 0
}
function isInArray(moves, piece, element, index, array){
return moves[element] && moves[element] == piece; // check if there's a piece assigned to a specific block and that piece is the piece we're looking for (either "X" or "O")
}
this.possible_combinations.forEach((p_row) => {
if(p_row.every(isInArray.bind(null, moves, 'X'))){
pieces['X'] += 1;
}else if(p_row.every(isInArray.bind(null, moves, 'O'))){
pieces['O'] += 1;
}
});
this.setState({
x_score: pieces['X'],
o_score: pieces['O']
});
}
Here’s the render()
method. It uses the generateRows()
method to generate the content for the board. Below that is the score display for the two users.
render() {
return (
<View style={styles.board_container}>
<View style={styles.board}>
{this.generateRows()}
</View>
<View style={styles.scores_container}>
<View style={styles.score}>
<Text style={styles.user_score}>{this.state.x_score}</Text>
<Text style={styles.username}>{this.props.username} (x)</Text>
</View>
<View style={styles.score}>
<Text style={styles.user_score}>{this.state.o_score}</Text>
<Text style={styles.username}>{this.props.rival_username} (o)</Text>
</View>
</View>
</View>
);
}
Here’s the generateRows()
method:
generateRows() {
return this.rows.map((row, index) => {
return (
<View style={styles.row} key={index}>
{this.generateBlocks(row, index)}
</View>
);
});
}
The generateBlocks()
method is used for generating the individual blocks on each row. It uses the TouchableHighlight
component to create a view which can be tapped on by the user. Each block displays the piece of the user who first tapped on it. Tapping on a block executes the onMakeMove()
method which places the user’s piece on that block.
generateBlocks(row, row_index) {
return row.map((block, index) => {
let id = this.ids[row_index][index];
return (
<TouchableHighlight
key={index}
onPress={this.onMakeMove.bind(this, row_index, index)}
underlayColor={"#CCC"}
style={styles.block}>
<Text style={styles.block_text}>
{this.state.moves[id]}
</Text>
</TouchableHighlight>
);
});
}
The onMakeMove()
method receives the row_index
and the block index
. These allow us to get the block id
which is used to set the piece on a specific block. After that, the updateScores()
is also called to update the user scores. To update the UI of the rival, the details of the move is sent using the client-make-move
event.
onMakeMove(row_index, index) {
let moves = this.state.moves;
let id = this.ids[row_index][index];
if(!moves[id]){ // nobody has occupied the space yet
moves[id] = this.props.piece;
this.setState({
moves
});
this.updateScores.call(this, moves);
// inform the rival that a move is made
this.props.channel.trigger('client-make-move', {
row_index: row_index,
index: index,
piece: this.props.piece
});
}
}
Once the board has been filled up with pieces, ask the room creator if they want to restart or end the game. If the room creator decides to restart the game the board is simply reset to its default state, otherwise the app is reset to its default state (the same as when the app is first opened).
if(this.props.is_room_creator && moves.indexOf('') == -1){
Alert.alert(
"Restart Game",
"Do you want to restart the game?",
[
{
text: "Nope. Let's call it quits.",
onPress: () => {
this.setState({
moves: range(9).fill(''),
x_score: 0,
o_score: 0
});
this.props.endGame();
},
style: 'cancel'
},
{
text: 'Heck yeah!',
onPress: () => {
this.setState({
moves: range(9).fill(''),
x_score: 0,
o_score: 0
});
}
},
],
{ cancelable: false }
);
}
Lastly, add the styles:
const styles = StyleSheet.create({
board_container: {
flex: 9
},
board: {
flex: 7,
flexDirection: 'column'
},
row: {
flex: 1,
flexDirection: 'row',
borderBottomWidth: 1,
},
block: {
flex: 1,
borderRightWidth: 1,
borderColor: '#000',
alignItems: 'center',
justifyContent: 'center'
},
block_text: {
fontSize: 30,
fontWeight: 'bold'
},
scores_container: {
flex: 2,
flexDirection: 'row',
alignItems: 'center'
},
score: {
flex: 1,
alignItems: 'center'
},
user_score: {
fontSize: 25,
fontWeight: 'bold'
},
username: {
fontSize: 20
}
});
Testing the app
Now that you’ve built the app, it’s now time to try it out. The first thing that you need to do is run the server:
node server.js
You can run the app with the following command:
react-native run-android
Be sure that you already have a connected device or an emulator opened when you execute this.
If you’re using either Genymotion or the Android emulator, and you don’t really want to test on a real device, then you can use the browser to simulate the rival.
Once that’s done, run the app and create a new room. Then copy the room ID shown in the alert box.
Next, go to your Pusher app’s dashboard and click on the Debug Console tab.
Click on Show event creator and enter private-ROOM_ID
for the Channel. Be sure to replace ROOM_ID
with the actual ID of the room then set client-joined
as the value of the Event. The value for the Data is:
{
"username": "doraemon"
}
Use the screenshot below as a reference:
Once that’s done, click on the Send event button. This should trigger the app to change its UI to the actual game board. To trigger some moves, set the Event name to client-make-move
then add the details of the move on the Data field:
{
"row_index": 0,
"index": 0,
"piece": "O"
}
This will place the “O” piece on the first box in the game board.
From there you can place a different value for the index
and row_index
to emulate the game play.
Deploying the server
The method I showed you above is great if you only want to test inside your own local network. But what if you want to test it out with your friends outside the network? For that, you could use Now. I’m not going to go into details on how to deploy the server, but you can check out their docs. Now is free to use, the only downside is that your code will be available publicly.
Conclusion
That’s it! In this tutorial you’ve learned how to re-create Tic-Tac-Toe using Pusher. As you have seen, Pusher really makes it easy to implement real-time features in games. While Tic-Tac-Toe is a very simple game, this doesn’t mean that Pusher can only be used in simple games. You can pretty much use Pusher in any real-time game you can think of. You can find the source code for this tutorial on GitHub
11 May 2018
by Wern Ancheta