Tic tac toe is a quintessential childhood game. All it requires is something to write on and something to write with. But what if you want to play with someone that’s in another location? In this case, you would need to use an application that connects you and another player to the game.
The application needs to provide a realtime experience so every move you make is seen instantly by the other player and vice versa. If the application does not provide this experience, then you and many people, won’t probably use it anymore.
So how does a developer provide a connected experience where the players can play tic tac toe, or any game, no matter where they are in the world?
Realtime Multiplayer Game Concepts
There are several ways to provide the realtime infrastructure for multiplayer games. You can go the route of building your own infrastructure from the ground up by using technologies and open-source protocols like Socket.IO, SignalR, or WebSockets.
While this may seem like an appealing route to take, you will encounter several issues; one such issue being scalability. It’s not hard to handle 100 users, but how do you handle 100,000+ users? Besides infrastructure issues, you still have to worry about maintaining your game.
At the end of the day, the only thing that matters is providing a great experience for the players of your game. But how do you do solve the infrastructure problem? This is where PubNub comes in.
PubNub provides the realtime infrastructure to power any application through its global Data Stream Network. With over 70+ SDKs, including the most popular programming languages, PubNub simplifies sending and receiving messages to any device in under 100 ms. It is secure, scalable and reliable so you don’t have to worry about creating and maintaining your own infrastructure.
In order to show how easy it is to develop a multiplayer game using PubNub, we will build a simple React tic tac toe game using the PubNub React SDK. In this game, two players will connect to a unique game channel where they will play against each other. Every move a player makes will be published to the channel to update the other player’s board in realtime.
App Overview
Here is what our app will look like once we finish. Click here to try out our live version of the game.
Players first join the lobby where they can create a channel or join a channel. If the player creates a channel, they get a room id to share with another player. The player that created the channel becomes Player X and will make the first move when the game starts.
The player that joins a channel with the room id they were given becomes Player O. Players can only join channels when there is one other person in the channel. If there is more than one person, then a game is in progress for that channel and the player won’t be able to join. The game starts once there are two players in the channel.
At the end of the game, the winner’s score is incremented by one point. If the game ends in a tie, then neither player is awarded a point. A modal is displayed to Player X asking them to start a new round or to end the game. If Player X continues the game, the board resets for the new round. Otherwise, the game ends and both players go back to the lobby.
Set Up the Lobby
Before we set up the lobby, sign up for a free PubNub account to get your free Pub/Sub API keys. Sign up and log in using the form below:
Once you get your keys, insert them into the constructor of App.js.
// App.js import React, { Component } from 'react'; import Game from './Game'; import Board from './Board'; import PubNubReact from 'pubnub-react'; import Swal from "sweetalert2"; import shortid from 'shortid'; import './Game.css'; class App extends Component { constructor(props) { super(props); // REPLACE with your keys this.pubnub = new PubNubReact({ publishKey: "YOUR_PUBLISH_KEY_HERE", subscribeKey: "YOUR_SUBSCRIBE_KEY_HERE" }); this.state = { piece: '', // X or O isPlaying: false, // Set to true when 2 players are in a channel isRoomCreator: false, isDisabled: false, myTurn: false, }; this.lobbyChannel = null; // Lobby channel this.gameChannel = null; // Game channel this.roomId = null; // Unique id when player creates a room this.pubnub.init(this); // Initialize PubNub } render() { return (); } } export default App;
Also in the constructor, the state objects and variables are initialized. We will go over the objects and the variables when they come up throughout the file. Finally, we initialized PubNub at the end of the constructor.
Inside of the render method and inside the return statement, we add the markup for the Lobby component.
return ( <div> <div className="title"> <p> React Tic Tac Toe </p> </div> { !this.state.isPlaying && <div className="game"> <div className="board"> <Board squares={0} onClick={index => null} /> <div className="button-container"> <button className="create-button " disabled={this.state.isDisabled} onClick={(e) => this.onPressCreate()} > Create </button> <button className="join-button" onClick={(e) => this.onPressJoin()} > Join </button> </div> </div> </div> } { this.state.isPlaying && <Game pubnub={this.pubnub} gameChannel={this.gameChannel} piece={this.state.piece} isRoomCreator={this.state.isRoomCreator} myTurn={this.state.myTurn} xUsername={this.state.xUsername} oUsername={this.state.oUsername} endGame={this.endGame} /> } </div> );
The Lobby component consists of a title, an empty tic tac toe board (nothing happens if the player presses the squares) and the ‘Create‘ and ‘Join‘ buttons. This component is displayed only if the state value isPlaying is false. If it’s set to true, then the game has begun and the component is changed to the Game component, which we will go over in the second part of the tutorial.
The Board component is part of the Lobby component as well. Within the Board component is the Square component. We won’t go into detail for these two components in order to focus on the Lobby and Game components.
When the player presses the ‘Create’ button, the button is disabled so the player can’t create multiple channels. The ‘Join’ button is not disabled, just in case the player decides to join a channel instead. Once the ‘Create’ button is pressed, the method onPressCreate() is called.
Create a Channel
The first thing we do in onPressCreate() is generate a random string id that’s truncated to 5 characters. We do so by using shortid(). We append the string to ‘tictactoelobby–‘, which will be the unique lobby channel players subscribe to.
// Create a room channel onPressCreate = (e) => { // Create a random name for the channel this.roomId = shortid.generate().substring(0,5); this.lobbyChannel = 'tictactoelobby--' + this.roomId; // Lobby channel name this.pubnub.subscribe({ channels: [this.lobbyChannel], withPresence: true // Checks the number of people in the channel }); }
In order to prevent more than two players from joining a given channel, we use PubNub Presence. Later on, we will look at the logic for checking the occupancy of the channel.
Once the player subscribes to the lobby channel, a modal is displayed with the room id so another player can join that channel.
This modal, and all the modals used in this app, are created by SweetAlert2 to replace JavaScript’s default alert() popup boxes.
// Inside of onPressCreate() // Modal Swal.fire({ position: 'top', allowOutsideClick: false, title: 'Share this room ID with your friend', text: this.roomId, width: 275, padding: '0.7em', // Custom CSS to change the size of the modal customClass: { heightAuto: false, title: 'title-class', popup: 'popup-class', confirmButton: 'button-class' } })
At the end of onPressCreate(), we change the state values to reflect the new state of the app.
this.setState({ piece: 'X', isRoomCreator: true, isDisabled: true, // Disable the 'Create' button myTurn: true, // Player X makes the 1st move });
Once the player creates a room, they have to wait for another player to join that room. Let’s look at the logic for joining a room.
Join a Channel
When a player presses the ‘Join’ button, a call to onPressJoin() is called. A modal is displayed to the player asking them to enter the room id in the input field.
If the player types in the room id and presses the ‘OK’ button, then joinRoom(value) is called where value is the room id. This method is not called if the input field is empty or if the player presses the ‘Cancel’ button.
// The 'Join' button was pressed onPressJoin = (e) => { Swal.fire({ position: 'top', input: 'text', allowOutsideClick: false, inputPlaceholder: 'Enter the room id', showCancelButton: true, confirmButtonColor: 'rgb(208,33,41)', confirmButtonText: 'OK', width: 275, padding: '0.7em', customClass: { heightAuto: false, popup: 'popup-class', confirmButton: 'join-button-class', cancelButton: 'join-button-class' } }).then((result) => { // Check if the user typed a value in the input field if(result.value){ this.joinRoom(result.value); } }) }
The first thing we do in joinRoom() is append value to ‘tictactoelobby–‘, similar to what we did in onPressCreate().
// Join a room channel joinRoom = (value) => { this.roomId = value; this.lobbyChannel = 'tictactoelobby--' + this.roomId; }
Before the player subscribes to the lobby channel, we have to check the total occupancy of the channel by using hereNow(). If the total occupancy is less than 2, the player can successfully subscribe to the lobby channel.
// Check the number of people in the channel this.pubnub.hereNow({ channels: [this.lobbyChannel], }).then((response) => { if(response.totalOccupancy < 2){ this.pubnub.subscribe({ channels: [this.lobbyChannel], withPresence: true }); this.setState({ piece: 'O', // Player O }); this.pubnub.publish({ message: { notRoomCreator: true, }, channel: this.lobbyChannel }); } }).catch((error) => { console.log(error); });
After the player subscribes to the lobby channel, the state value of piece is changed to ‘O’ and a message is published to that lobby channel. This message notifies the Player X that another player has joined the channel. We set up the message listener in componentDidUpdate(), which we will get to shortly.
If the total occupancy is greater than 2, then a game is in progress and the player attempting to join the channel will be denied access. The following code is below the if statement in hereNow().
// Below the if statement in hereNow() else{ // Game in progress Swal.fire({ position: 'top', allowOutsideClick: false, title: 'Error', text: 'Game in progress. Try another room.', width: 275, padding: '0.7em', customClass: { heightAuto: false, title: 'title-class', popup: 'popup-class', confirmButton: 'button-class' } }) }
Let’s now take a look at componentDidUpdate().
Start the Game
In componentDidUpdate(), we check if the player is connected to a channel, that is, check that this.lobbyChannel is not null. If it’s not null, we set up a listener that listens to all messages that arrive on the channel.
componentDidUpdate() { // Check that the player is connected to a channel if(this.lobbyChannel != null){ this.pubnub.getMessage(this.lobbyChannel, (msg) => { // Start the game once an opponent joins the channel if(msg.message.notRoomCreator){ // Create a different channel for the game this.gameChannel = 'tictactoegame--' + this.roomId; this.pubnub.subscribe({ channels: [this.gameChannel] }); } }); } }
We check if the message arrived is msg.message.notRoomCreator, which is published by the player that joins the channel. If so, we create a new channel, ‘tictactoegame–‘, with the room id appended to the string. The game channel is used to publish all the moves made by the players which will update their boards.
Finally, after subscribing to the game channel, the state value of isPlaying is set to true. Doing so will replace the lobby component with the game component.
this.setState({ isPlaying: true }); // Close the modals if they are opened Swal.close(); }
Once the game component is shown, we want to close all the modals, if opened, from the Lobby component by doing Swal.close().
Now that we have two players connected to a unique game channel, they can begin playing tic tac toe! In the next section, we will implement the UI and logic for the game component.
Build Game Features
The first thing we do in Game.js is set up the base constructor:
// Game.js import React from 'react'; import Board from './Board'; import Swal from "sweetalert2"; class Game extends React.Component { constructor(props) { super(props); this.state = { squares: Array(9).fill(''), // 3x3 board xScore: 0, oScore: 0, whosTurn: this.props.myTurn // Player X goes first }; this.turn = 'X'; this.gameOver = false; this.counter = 0; // Game ends in a tie when counter is 9 } render() { return (); } } export default Game;
For the state objects, we initialize the array squares property, which is used to store the player’s positions in the board. This will be explained furthermore below. We also set the players score to 0 and set the value of whosTurn to myTurn, which is initialized to true for Player X and false for Player O.
The value of the variables turn and counter will change throughout the progression of the game. At the end of the game, gameOver is set to true.
Add the UI
Next, let’s set up the markup for the Game component inside of the render method.
render() { let status; // Change to current player's turn status = `${this.state.whosTurn ? "Your turn" : "Opponent's turn"}`; return ( <div className="game"> <div className="board"> <Board squares={this.state.squares} onClick={index => this.onMakeMove(index)} /> <p className="status-info">{status}</p> </div> <div className="scores-container"> <div> <p>Player X: {this.state.xScore} </p> </div> <div> <p>Player O: {this.state.oScore} </p> </div> </div> </div> ); }
We show the value of status in the UI to let the players know if it’s their turn to make a move or if the other player’s turn. The boolean value of the state whosTurn is updated every time a move is made. The rest of the UI consists of the Board component and the player’s score.
Add the Logic
When the player makes a move on the board, a call to onMakeMove(index) is made where index is the position the piece is placed on the board. The board has 3 rows and 3 columns, so 9 squares total. Each square has its own unique index value, starting with the value 0 and ending with the value 8.
onMakeMove = (index) =>{ const squares = this.state.squares; // Check if the square is empty and if it's the player's turn to make a move if(!squares[index] && (this.turn === this.props.piece)){ squares[index] = this.props.piece; this.setState({ squares: squares, whosTurn: !this.state.whosTurn }); // Other player's turn to make a move this.turn = (this.turn === 'X') ? 'O' : 'X'; // Publish move to the channel this.props.pubnub.publish({ message: { index: index, piece: this.props.piece, turn: this.turn }, channel: this.props.gameChannel }); // Check if there is a winner this.checkForWinner(squares) } }
After getting the state of the array squares, a conditional statement is used to check if the square the player touched is empty and if it’s their turn to make a move. If one or both conditions are not met, then the player’s piece is not placed on the square. Otherwise, the player’s piece is added to the array squares in the index the piece was placed on.
For example, if Player X makes a move in row 0, column 2 and the conditional statement is true, then squares[2] will have the value of “X”.Next, the state is changed to reflect the new state of the game and turn is updated so the other player can make their move. In order for the other player’s board to update with the current data, we publish the data to the game channel. All of this is happening in realtime, so both players will immediately see their boards update as soon as a valid move is made. The last thing to do in this method is to call checkForWinner(squares) to check if there is a winner.
Before we do that, let’s take a look at componentDidMount() where we set up the listener for new messages that arrive in the game channel.
componentDidMount(){ this.props.pubnub.getMessage(this.props.gameChannel, (msg) => { // Update other player's board if(msg.message.turn === this.props.piece){ this.publishMove(msg.message.index, msg.message.piece); } }); }
Since both players are connected to the same game channel, they will both receive this message. The method publishMove(index, piece) is called, where index is the position that piece was placed and piece is the piece of the player that made the move. This method updates the board with the current move and checks if there is a winner. To prevent the player that made the current move to have to redo this process again, the if statement checks if the player’s piece matches the value of turn. If so, their board is updated.
// Opponent's move is published to the board publishMove = (index, piece) => { const squares = this.state.squares; squares[index] = piece; this.turn = (squares[index] === 'X')? 'O' : 'X'; this.setState({ squares: squares, whosTurn: !this.state.whosTurn }); this.checkForWinner(squares) }
The logic of updating the board is the same as onMakeMove(). Let’s now go over checkForWinner().
checkForWinner = (squares) => { // Possible winning combinations const possibleCombinations = [ [0, 1, 2], [3, 4, 5], [6, 7, 8], [0, 3, 6], [1, 4, 7], [2, 5, 8], [0, 4, 8], [2, 4, 6], ]; // Iterate every combination to see if there is a match for (let i = 0; i < possibleCombinations.length; i += 1) { const [a, b, c] = possibleCombinations[i]; if (squares[a] && squares[a] === squares[b] && squares[a] === squares[c]) { this.announceWinner(squares[a]); return; } } }
All the winning combinations are in the double array possibleCombinations, where every array is a possible combination to winning the game. Every array in possibleCombinations is checked against the array squares. If there is a match, then there is a winner. Let’s follow an example to make this more clear.
Say that Player X makes a winning move in row 2 column 0. The index of that position is 6. The board now looks like this:
The winning combination for Player X is [2,4,6]. The array squares is updated to: [“O”, “”, “X”, “O”, “X”, “”, “X”, “”, “”].
In the for loop, when [a,b,c] has the values of [2,4,6], the if statement in the for loop with be true since [2,4,6] all have the same value of X. The score of the winner needs to be updated, so announceWinner() is called to award the winning player.
If the game ends in a tie, there is no winner for that round. To check for tie games, we use a counter that increments by one every time a move is made on the board.
// Below the for loop in checkForWinner() // Check if the game ends in a draw this.counter++; // The board is filled up and there is no winner if(this.counter === 9){ this.gameOver = true; this.newRound(null); }
If the counter reaches 9, then the game ends in a draw because the player did not make a winning move in the last square of the board. When this happens, the method newRound() is called with a null argument since there is no winner.
Before we go to this method, let’s go back to announceWinner().
// Update score for the winner announceWinner = (winner) => { let pieces = { 'X': this.state.xScore, 'O': this.state.oScore } if(winner === 'X'){ pieces['X'] += 1; this.setState({ xScore: pieces['X'] }); } else{ pieces['O'] += 1; this.setState({ oScore: pieces['O'] }); } // End the game once there is a winner this.gameOver = true; this.newRound(winner); }
The parameter of this method is winner, which is the player that won the game. We check if the winner is ‘X’ or ‘O’ and increment the winner’s score by one point. Since the game is over, the variable gameOver is set to true and the method newRound() is called.
Start a New Round
Player X has the option to play another round or to end the game and go back to the lobby.
The other player has told to wait until Player X decides what to do.
Once the Player X decides what to do, a message is published to the game channel to let the other player know. The UI is then updated.
newRound = (winner) => { // Announce the winner or announce a tie game let title = (winner === null) ? 'Tie game!' : `Player ${winner} won!`; // Show this to Player O if((this.props.isRoomCreator === false) && this.gameOver){ Swal.fire({ position: 'top', allowOutsideClick: false, title: title, text: 'Waiting for a new round...', confirmButtonColor: 'rgb(208,33,41)', width: 275, customClass: { heightAuto: false, title: 'title-class', popup: 'popup-class', confirmButton: 'button-class', } , }); this.turn = 'X'; // Set turn to X so Player O can't make a move } // Show this to Player X else if(this.props.isRoomCreator && this.gameOver){ Swal.fire({ position: 'top', allowOutsideClick: false, title: title, text: 'Continue Playing?', showCancelButton: true, confirmButtonColor: 'rgb(208,33,41)', cancelButtonColor: '#aaa', cancelButtonText: 'Nope', confirmButtonText: 'Yea!', width: 275, customClass: { heightAuto: false, title: 'title-class', popup: 'popup-class', confirmButton: 'button-class', cancelButton: 'button-class' } , }).then((result) => { // Start a new round if (result.value) { this.props.pubnub.publish({ message: { reset: true }, channel: this.props.gameChannel }); } else{ // End the game this.props.pubnub.publish({ message: { endGame: true }, channel: this.props.gameChannel }); } }) } }
If the message is reset, then all the state values and variables, except the score for the players, are reset to their initial values. Any modals that are still open are closed and a new round starts for both players.
For the message endGame, all the modals are closed and the method endGame() is called. This method is in App.js.
// Reset everything endGame = () => { this.setState({ piece: '', isPlaying: false, isRoomCreator: false, isDisabled: false, myTurn: false, }); this.lobbyChannel = null; this.gameChannel = null; this.roomId = null; this.pubnub.unsubscribe({ channels : [this.lobbyChannel, this.gameChannel] }); }
All the state values and variables are reset to their initial values. The channel names are reset to null because a new name is generated every time a player creates a room. Since the channel names won’t be useful anymore, the players unsubscribe from both the lobby and the game channel. The value of isPlaying is reset to false, so the game component will be replaced with the lobby component.
The last method to include in App.js is componentWillUnmount(), which unsubscribes the players from both channels.
componentWillUnmount() { this.pubnub.unsubscribe({ channels : [this.lobbyChannel, this.gameChannel] }); }
This is all we need to do for the game to work! You can get the CSS file for the game in the repo. Now, let’s get the game up and running.
Run the Game
There are a couple of small steps we need to do before running the game. First, we need to enable the PubNub Presence feature because we use it to get the number of people in the channel (we used withPresence when subscribing to the lobby channel). Go to the PubNub Admin Dashboard and click on your application. Click on Keyset and scroll down to Application add-ons. Toggle the Presence switch to on. Keep the default values the same.
To install the three dependencies used in the app and to run the app, you can run the script dependencies.sh that’s in the root directory of the app.
# dependencies.sh npm install --save pubnub pubnub-react npm install --save shortid npm install --save sweetalert2 npm start
In the terminal, go to the app’s root directory and type the following command to make the script executable:
chmod +x dependencies.sh
Run the script with this command:
./dependencies.sh
The app will open in http://localhost:3000 with the lobby component displaying.Open another tab, or preferably window, and copy and paste http://localhost:3000. In one window, create a channel by clicking the ‘Create’ button. A modal will pop up displaying the room id. Copy and paste that id. Go to the other window and click the ‘Join’ button. When the modal pops up, type the room id in the input field and press the ‘Okay’ button.
Once the players are connected, the game will start. The window you used to create the channel makes the first move. Press any square on the board and see as the piece X is displayed on the board in realtime for both windows. If you try to press another square in the same board, nothing will happen because it’s no longer your turn to make a move. In the other window, press any square on the board and the piece O is placed in the square.
Keep on playing until there is a winner or a tie. A modal is then displayed announcing the winner of the round, or announcing that the game ended in a tie. In the same modal, Player X will have to decide whether to continue playing or to exit the game. The modal for Player O will tell them to wait for a new round.
Everything, except the score, is reset if Player X continues the game. Otherwise, both players are taken back to the lobby where they can create or join new channels. Check out the game demo below:
Create a Native Mobile Version
Now that you’ve got your game working beautifully in web browsers, let’s take it mobile! Check out how to build a Multiplayer Tic Tac Toe Game in React Native for Android and iOS. And if you want to build more realtime games and want to know how PubNub can help you with that, check out the multiplayer gaming tutorials.
Have suggestions or questions about the content of this post? Reach out at devrel@pubnub.com.