Build a Multiplayer Game with Twilio Sync: Part 2

Developers working on game
August 01, 2023
Written by
Carlos Mucuho
Contributor
Opinions expressed by Twilio contributors are their own
Reviewed by

This blog post is the second part of a two-part tutorial where you will learn how to create a multiplayer Tic Tac Toe game with the Twilio Sync Javascript SDK.

In the first part, you learned how to create a custom Tic Tac Toe game with a user interface, controls, game logic, and an AI player.

In this part, you will learn how to use Twilio Sync to incorporate real-time multiplayer functionality into the game. Instead of playing against an AI player, after you finish this part you will be able to play against a real human player.

Twilio Sync Javascript SDK is a software development kit that allows you to add real-time data synchronization functionality to your web and mobile applications.

By the end of this part, you will have a game that looks like the following:

A visual showing a multiplayer Tic Tac Toe game built with Twilio Sync

Tutorial Requirements

To follow this tutorial you will need the following:

  • A free Twilio account
  • A basic understanding of how to use Twilio and Javascript to build a applications
  • Node.js v12+, NPM, and Git installed

Project setup

In this section, you will clone a repository containing the code generated in the first part of this tutorial, initialize a new node project and install the packages required to create a web server for the multiplayer game.

Open a terminal window and navigate to a suitable location for your project, use the following commands to clone the repository containing the boilerplate code and navigate to the boilerplate directory.

git clone https://github.com/CSFM93/twilio-multiplayer-game-part1.git
cd twilio-multiplayer-game-part1

This code includes the tic tac toe game that was built in the first part of this tutorial. All the application files were stored inside the public directory.

Use the following command to create a new node project in this project’s root directory:

npm init -y

Now, use the following command to install the packages needed to build this application:

npm install dotenv express twilio

With the command above you installed the following packages:

  • dotenv: is a package that allows you to load environment variables from a .env file without additional dependencies.
  • express: is a lightweight Node.js web application framework that offers a wide range of features for creating web and mobile apps. You will use the package to create the application server.
  • twilio: is a package that allows you to interact with the Twilio API.

Use the following command to install nodemon as a dev dependencies:

 

npm install nodemon --save-dev

nodemon is a Node.js development utility that automatically restarts your application when a file is changed.

Change the contents of the scripts located inside the package.json to the following:

"scripts": {
  "dev": "node_modules/nodemon/bin/nodemon.js server.js"
}

The code above ensures that you start the server using nodemon when you run the npm run dev command.

Collecting and storing your Twilio credentials

Create a file named .env in this project's root directory. This file is where you will store your Twilio account credentials that will be needed to create access tokens for the Twilio Sync API. In total, you will need to collect and store four twilio credentials.

The first credential required is the Account SID, which is located in the Twilio Console. This credential should be stored in the .env file once obtained:

TWILIO_ACCOUNT_SID=<Your Account SID>

The second and third required credentials are an API Key SID and API Key Secret, which can be obtained by following this guide. After obtaining both credentials, copy and store them in the .env file:

TWILIO_ACCOUNT_SID=<Your Account SID>
TWILIO_API_KEY_SID=<Your API Key>
TWILIO_API_KEY_SECRET=<Your Secret Key>

After generating an API key you will only be able to see the API Key Secret one time. Be sure to copy and paste it somewhere safe, or directly into your .env file right away.

The fourth credential required is a Sync Service SID. A Sync Service is the top-level scope of all other Sync resources (Maps, Lists, Documents).

Navigate to the Twilio Sync Service page, create a new Sync Service, and copy the Sync Service SID. This credential should also be stored in the .env file once obtained:

TWILIO_ACCOUNT_SID=<Your Account SID>
TWILIO_API_KEY_SID=<Your API Key>
TWILIO_API_KEY_SECRET=<Your Secret Key>
TWILIO_SYNC_SERVICE_SID=<Your Sync Service SID>

Creating the server

In this section, you will create the server application responsible for managing game rooms and generating Twilio Sync access tokens.

Game rooms are virtual spaces with unique identifiers within multiplayer games where players can interact and participate in specific game sessions. The game room that you will implement in this tutorial will be very simple, it will be responsible for storing the players’ signs (O or X) and limiting the number of players in a given room to 2. The players’ interaction will be handled by Twilio Sync.

Navigate to your project’s root directory, create a file named server.js, and add the following code to it:

const express = require('express');
require('dotenv').config();
const app = express();
const AccessToken = require('twilio').jwt.AccessToken;
const SyncGrant = AccessToken.SyncGrant

const port = 3000;
const rooms = {}

app.use(express.json());
app.use(express.static('public'));

The code above sets up a server using the Express framework. It imports the required modules, including Express and Twilio, and configures the server to listen on port 3000. It also initializes an empty object to store information about game rooms. The server is configured to parse JSON data in requests and serve static files from the public directory.

Add the following code to the bottom of your server.js file:

function createOrJoinRoom(roomName) {
  if (Object.keys(rooms).includes(roomName)) {
    if (rooms[roomName].length < 2) {
      const playerSign = rooms[roomName].includes('X') ? 'O' : 'X'
      rooms[roomName].push(playerSign)
      return playerSign
    } else {
      return ''
    }
  } else {
    let playerSign = 'O'
    rooms[roomName] = [playerSign]
    return playerSign
  }
}

Here a function named  createOrJoinRoom() was initialized, this function allows players to create or join a room in the game server. It checks if the specified room already exists and if it has space for another player.

If the room exists and has space (has less than two players), a player sign (X or O) is assigned to the new player and added to the room. The assigned player sign is returned. If the room is full, an empty string is returned.

If the room doesn't exist, a new room is created with the specified name, the first player is assigned the player sign O, and O is returned as the player sign.

Add the following code below the createOrJoinRoom() function:

function leaveRoom(roomName, sign) {
  if (Object.keys(rooms).includes(roomName)) {
    const index = rooms[roomName].indexOf(sign);
    if (rooms[roomName].length > 0 && index > -1) {
      rooms[roomName].splice(index, 1);
      return true
    } else {
      return false
    }
  } else {
    return false
  }
}

The leaveRoom() function initialized above handles a player leaving a room in the Tic Tac Toe game server. It checks if the specified room exists and if the player's sign is present in the room's array of players.

If both conditions are met, the player is removed from the room by splicing the array. The function returns true if the player was successfully removed and false otherwise.

If the specified room doesn’t exist it returns false.

Add the following code below the leaveRoom() function:

app.get('/joinRoom', (req, res) => {
  const roomName = req.query.roomName
  const playerSign = createOrJoinRoom(roomName)

  if (playerSign === '') {
    res.send({
      playerSign: '',
    });
  } else {
  }
});

The code above initializes a route handler for the /joinRoom endpoint in the server application.

When a GET request is made to this endpoint, it extracts the roomName parameter from the request query. It then calls the createOrJoinRoom() function to determine the player's sign and join the room if it exists and if it isn’t full.

If the room is full, it sends a response with an empty player sign.

Add the following code to the else statement inside the /joinRoom route:

app.get('/joinRoom', (req, res) => {
…
  if (playerSign === '') {
    …
     } else {
    const identity = "tic-tac-toe"
    const token = new AccessToken(
      process.env.TWILIO_ACCOUNT_SID,
      process.env.TWILIO_API_KEY_SID,
      process.env.TWILIO_API_KEY_SECRET,
      { identity: identity },
    );

    const syncGrant = new SyncGrant({
      serviceSid: process.env.TWILIO_SYNC_SERVICE_SID,
    });
    token.addGrant(syncGrant);

    res.send({
      playerSign: playerSign,
      token: token.toJwt(),
      participants: rooms[roomName].length
    });
  }
});

In the else statement above—which runs when the room isn’t full—the code generates a Twilio access token for the player, including their identity and necessary permissions. Lastly, it sends a response containing the player's sign, token, and the number of participants in the room to the client.

We set the player's identity to a fixed value because we don't require user identification. If you do need to consider the user's identity, you should modify the code accordingly to meet your specific requirements.

Add the following code to the below the /joinRoom route:

app.get('/leaveRoom', (req, res) => {
  const roomName = req.query.roomName
  const sign = req.query.sign
  const exitedRoom = leaveRoom(roomName, sign)

  res.send({
    exitedRoom: exitedRoom
  });
});

app.listen(port, () => {
  console.log(`App listening at http://localhost:${port}`);
});

Here the code initializes a route handler for the  /leaveRoom endpoint in the server.

When a GET request is made to this route, the server retrieves the roomName and sign parameters from the request query. The leaveRoom() function is then called with these parameters to remove the specified sign from the room. The response sent back to the client includes the result of the operation.

Finally, the server starts listening on the specified port and logs a message indicating it's running and listening for requests at the specified port.

Please note that the current implementation lacks an authentication system and allows anyone to join a game room. Make sure you implement your authentication system before moving this application to production.

Run the following command below to start serving the application:

npm run dev

Open your preferred browser, navigate to http://localhost:3000/ and you should see the game UI:

Tic Tac Toe game UI

Joining and leaving rooms

In this section, you will add the Twilio Sync Javascript SDK to the game client and incorporate the functionality that will allow multiple players to join and leave rooms.

Navigate to the public directory and add the following code to the head tag of the index.html file:

…

  <head>
    <script src="https://sdk.twilio.com/js/sync/releases/3.0.1/twilio-sync.min.js"></script>
  </head>

…

With the line of code above you added the Twilio Sync Javascript SDK to the application.

Add the following code below the modalGameOver in the body section of the index.html file.

<body>
  <div id="modalJoinRoom" class="modal" tabindex="-1">
    <div class="modal-dialog modal-dialog-centered">
      <div class="modal-content text-light text-center">
        <div class="modal-header">
          <h3 class="modal-title">Join room</h3>
          <button type="button" class="btn-close" data-bs-dismiss="modal"                         aria-label="Close"></button>
        </div>
        <div class="modal-body">
          <input type="text" class="form-control" id="inputRoomName" placeholder="enter your room name">
        </div>
        <div class="modal-footer ">
          <button type="button" class="btn text-light" data-bs-dismiss="modal" style="background-color: #362746;"
            onclick="initialize()">Join</button>
        </div>
      </div>
    </div>
  </div>
  <div class="toast-container position-fixed top-50 start-50 translate-middle p-3 ">
    <div id="toast" class="toast align-items-center text-bg-success border-0" role="alert" aria-live="assertive"
      aria-atomic="true">
      <div class="d-flex">
        <div class="toast-body">
          <h3 id="toast-text"></h3>
        </div>
        <button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast"
          aria-label="Close"></button>
      </div>
    </div>
  </div>
…
</body>

Here a modal and a toast, modalJoinRoom and toast respectively, were added to the application.

The join room modal will be displayed when the page loads and it will prompt the player to write the room name that he would like to join. The toast will be displayed when another player joins or leaves the room that the current player is in.

Open the index.js file located in the public directory and replace the value stored in the player variable (located around line 6) with an empty string:

let player = '';

Here you replaced the previous value stored in the player value because the player sign will be dynamically set.

Add the following code below the line where you declared the isGameOver variable (located around line 10):

let twilioSyncClient; 
let roomName
let roomParticipants
const modalJoinRoom = new bootstrap.Modal('#modalJoinRoom')
modalJoinRoom.show()

The code above sets up the necessary elements for joining a room in the application. It initializes variables that will be used to store the Twilio client, room name, and room participants. It also displays the join room modal that the players will use to join a room.

Add the following code to the bottom of your index.js file:

async function joinRoom() {
  let data = await fetch(`/joinRoom?roomName=${roomName}`)
    .then(async (response) => {
      let data = await response.json()
      console.log('res', data)
      return data
    })
  return data
}

Here the code defines a function named joinRoom(), this function sends a request to the server to join a room and asynchronously waits for the response. It retrieves the data in the response and returns it for further processing.

Add the following code below the joinRoom() function:

async function leaveRoom() {
  await fetch(`/leaveRoom?roomName=${roomName}&sign=${player}`)
  .then(async (response) => {
    let data = await response.json()
    console.log('res', data)
  })
}

The code above defines a function named leaveRoom(), this function sends a request to the server to leave the current room and logs the response received from the server.

Add the following code below the leaveRoom() function:

async function initialize() {
  btnStart.disabled = true;
  roomName = document.getElementById('inputRoomName').value.trim()
  const data = await joinRoom();
  player = data.playerSign;
  currentPlayer = player

  if (player !== '') {
    roomParticipants = data.participants
   if (roomParticipants == 2) {
      btnStart.disabled = false;
      const opponentPlayer = player === 'X' ? 'O' : 'X';
      document.getElementById('toast-text').innerText = `Player ${opponentPlayer} has already joined ${roomName}`
      new bootstrap.Toast('#toast').show()
    }

    indicatePlayerSign();
  } else {
    alert('Room is full');
  }
}

Here the function named initialize() that you added earlier to your join room modal was defined, this function disables the Start button, retrieves the room name, and joins a room by calling the joinRoom() function. Next, It assigns the player sign and the current player based on the received data.

If the player sign is not an empty string (meaning that the player successfully joined the room), it updates the room participants count, and checks if there are two participants in the room. If that is the case it enables the Start button, updates the toast element text to indicate that an opponent player has already joined the room, and shows the toast. Next, it calls the indicatePlayerSign() function to indicate the player sign on the game toolbar.

However, if the player sign is an empty string, it alerts the player that the room is full.

Add the following code below the indicatePlayerSign() function call:

async function initialize() {
  …
  if (player !== '') {
    …
    const token = data.token;
    twilioSyncClient = new Twilio.Sync.Client(token);
    twilioSyncClient.document(roomName)
      .then((document) => {
        updateDocument('playerJoined', player);

        document.on('updated', (event) => {
          if (!event.isLocal) {
            handleDocumentUpdate(event.data.content);
          }
        });
      })
      .catch((error) => {
        console.error('Unexpected error', error);
      });

  } 
  …
}

The code added retrieves the access token stored on the data returned by the server and uses it to create a Twilio Sync client for real-time synchronization. The client is used to access a document associated with a specific room. A function named updateDocument() function is then called to send events to update the document and notify the other player that a new player has joined the room.

The document object has an event listener for updates, which triggers a callback function when changes occur. The function checks if the update is made by another player (not local) and then calls a function named handleDocumentUpdate() to handle the updated data.

If any errors occur during the initialization process, such as client creation or document retrieval failures, an error message is logged to the console.

Add the following code below the initialize() function;

function updateDocument(event, value) {
  twilioSyncClient.document(roomName).then((doc) => {
    doc.update({ content: { event: event, value: value } });
  });
}

The code above defines the function named  updateDocument() that takes two parameters: event and value. This function is responsible for sending events to update the content of a document associated with a specific room.

Add the following code below the updateDocument() function:

function handleDocumentUpdate(data) {
  switch (data.event) {
    case 'playerJoined':
      if (roomParticipants < 2) {
        roomParticipants += 1
      }
      btnStart.disabled = false;
      document.getElementById('toast-text').innerText = `Player ${data.value} has joined ${roomName}`
      new bootstrap.Toast('#toast').show()
      break;
    default:
      break;
  }
}

Here the code defines the handleDocumentUpdate() function, which receives a data parameter and is responsible for processing updates to a document. This function will perform various actions based on the type of event that the data parameter has, such as notifying that another player has joined or left the room, updating the game board, and indicating players' turn.

Currently, it only handles the case when the event is playerJoined. If the event is playerJoined, it increments the roomParticipants variable if it is less than 2, enables the Start button, updates the text of the toast element to indicate the player who joined, and displays a Bootstrap toast message.

Add the following case below the playerJoined case:

function handleDocumentUpdate(data) {
  switch (data.event) {
    …
   case 'playerLeft':
      if (roomParticipants > 0) {
        roomParticipants -= 1
      }
     if (player !== data.value) {
        document.getElementById('toast-text').innerText = `Player ${data.value} has left ${roomName}`
        new bootstrap.Toast('#toast').show()
      }
      break;
    …
  }
}

The code added handles the playerLeft event. The code first checks if the variable roomParticipants is greater than 0. If it is, it decrements the value of roomParticipants by 1.

Next, it checks if the player’s sign matches the value received.  if it doesn’t match the code updates the toast element text to indicate that the opponent player left the room and then displays the toast.

Add the following code below the handleDocumentUpdate() function:

window.addEventListener('pagehide', async () => {
  updateDocument('playerLeft', player);
  await leaveRoom();
});

window.addEventListener('beforeunload', async () => {
  updateDocument('playerLeft', player);
  await leaveRoom();
});

The code above adds event listeners to the window object for the pagehide and beforeunload events. These events are triggered when the player navigates away from the current page or closes the browser window.

When either one of these events is triggered, the code first, calls the updateDocument() function to update the document associated with the room, indicating that the player has left the room then it calls the leaveRoom() function to send a request to the server to allow the player to leave the room.

Go back to your browser, open another browser window, and place it side by side with the first one. Refresh the page on the first window and navigate to http://localhost:3000/  on the second window. Once both windows have loaded the pages, enter the same room name in the modals to join a room. In both windows, the game toolbar will be updated to indicate each player's sign and a toast message stating that the opponent player has joined the room will appear. After the toast disappears, select one of the windows, refresh the page, and a toast message stating that the opponent player has left the room will appear on the other window.

A visual showing players joining and leaving a room


If you use the Chrome browser to play the game in two separate windows the player left toast will appear when the other player leaves the room. However, if you use Firefox the notification won’t appear. There seems to be a bug when you try to update the document using Twilio Sync inside the pageHide event.

Sharing the players' inputs

In this section, you will write the code that will be responsible for sharing the players’ input events, such as clicking the Start button, hovering, and clicking a cell on the board.

Replace the code inside the Start button click event listener with the following (located around line 62):

btnStart.addEventListener('click', () => {
    switchPlayer();
    startGame();
    updateDocument('startGame', ''); 
});

Here before calling the startGame() function, the code now includes a call to the switchPlayer() function located in the game.js file. This function is responsible for switching the current player.

After calling startGame(), the code calls the updateDocument() function to send an event indicating the start of the game.

Now open the game.js file and add the following highlighted line to the switchPlayer() function (located around line 24):

function switchPlayer() {
  currentPlayer = currentPlayer === 'X' ? 'O' : 'X';
  indicatePlayerTurn();
  updateDocument('switchPlayer', currentPlayer);
}

The line added calls the updateDocument() function to send an event to switch the current player.

Navigate to the checkGameStatus() function (located around line 68) and comment out the code below the switchPlayer() function call:

function checkGameStatus() {
  if (checkWin()) {
    gameOver('win');
  } else if (checkTie()) {
    gameOver('tie');
  } else {
    switchPlayer();
    // if (currentPlayer === AI) {
    //   setTimeout(() => {
    //     AIPlayer();
    //   }, 1000);
    // }
  }
}

Here you commented out the code responsible for managing the AI player’s moves because in this part of the tutorial you are adding the multiplayer functionality.

Navigate to the reset() function (located around line 117) and comment out the switchPlayer() function call because this function is being called from the Start button click event listener:

function reset() {
  …
  // switchPlayer(); 
}

Now, go back to your index.js file and add the following code to the event listeners inside the addListenersToCells() function (located around line 28):

function addListenersToCells() {
  for (let i = 0; i < cells.length; i++) {
    cells[i].addEventListener('mouseover', () => {
      if (currentPlayer === player && !isGameOver && cells[i].children.length === 0) {
        …
        updateDocument('mouseover', i);
      }
    });

    cells[i].addEventListener('mouseout', () => {
      if (currentPlayer === player && !isGameOver && cells[i].children.length === 0) {
        …
        updateDocument('mouseout', i);
      }
    });

    cells[i].addEventListener('click', () => {
      if (currentPlayer === player && !isGameOver && cells[i].children.length === 0) {
        …
        updateDocument('click', i);
      }
    });
  }
}

In the code above the updateDocument() function is called within the mouseover, mouseout, and click event listeners. It is passed two arguments: the first argument is the event name (mouseover, mouseout, or click), and the second argument is the index i of the cell on which the event occurred. This way, the updateDocument() function is invoked with the appropriate event and the corresponding cell index to update the document accordingly.

Add the following cases to switch statement inside the handleDocumentUpdate() function (located around line 149) :

function handleDocumentUpdate(data) {
  switch (data.event) {
    …
    case 'startGame':
      startGame();
      console.log(player, 'current player', currentPlayer)
      break;
    case 'switchPlayer':
      currentPlayer = data.value;
      indicatePlayerTurn();
      break;
    …
  }
}

The code above adds the startGame and the switchPlayer event handlers.

When the event is startGame, the function calls the startGame() function, which initializes the game.

When the event is switchPlayer, the function updates the value of currentPlayer to the value provided in data.value. This change indicates that the turn has shifted to the opponent player. The function then calls the indicatePlayerTurn() function, which updates the user interface to indicate the current player's turn.

Add the following cases below the switchPlayer case:

function handleDocumentUpdate(data) {
  switch (data.event) {
    …
   case 'mouseover':
      cells[data.value].classList.add('shrink-grow');
      break;
    case 'mouseout':
      cells[data.value].classList.remove('shrink-grow');
      break;
    case 'click':
      currentPlayer = player === 'X' ? 'O' : 'X';
      cells[data.value].setAttribute('disabled', true);
      cells[data.value].classList.remove('shrink-grow');
      markCell(cells[data.value]);
      break;
    …
  }
}

When the event is mouseover, the function adds the CSS class shrink-grow to the cell element selected using the index stored in the data.value property. This class creates a shrink-grow visual effect when the mouse is hovering over the cell

When the event is mouseout, the function removes the CSS class shrink-grow from the cell element, thus removing the visual effect when the mouse moves out of the cell.

When the event is click, the function first updates the currentPlayer value to match the opponent player's sign since this code will only run if the opponent player makes a move. Next, it disables the cell element and removes the CSS class shrink-grow from the cell. Finally, it calls the markCell(), passing the cell element as an argument to mark the cell with the opponent player’s sign and check the game status.

Go back to the browser windows, refresh both of them, enter the same room name and play the game.

A visual showing a multiplayer Tic Tac Toe game built with Twilio Sync

Conclusion

In this second part of a two-part tutorial, you learned how to use the Twilio Sync Javascript SDK to incorporate real-time multiplayer functionality in the Tic Tac Toe game. First, you created a server capable of managing game rooms and generating Twilio Sync access tokens. Lastly, you added the Twilio Javascript SDK to the game and implemented the multiplayer functionality.