Skip to main content

Command Palette

Search for a command to run...

Build a WebSockets Powered Tic-tac-toe with AdonisJS

Published
8 min read
O

Hi there 👋🏾. I'm a software engineer that enjoys building stuff and talking about them. I also tinker a bit with hardware and robotics using Arduino and ROS.

Introduction

Browser-based multiplayer gaming requires split-second communication between players. Tic-Tac-Toe is no exception. Player1 needs to see Player2's move in record time. To achieve this communication speed on browsers, we will use the WebSocket API.

We will use AdonisJS, which is a Node.js MVC framework, on the backend. AdonisJS implements web sockets both on the client and server. Check out the completed app here.

Technologies Involved

  1. Node.js

  2. AdonisJS

  3. Redis

Prerequisites

Node.js

nodejs-logo.png

First, check that your Node.js version is >= 10.

$ node -v
# v10.19.0

Install the latest LTS version of Node.js from the official website if yours is less than v10.

Adonis CLI

adonis-logo.png

If you encounter Error: EACCES: permission denied prefix sudo to the command.

$ npm i -g @adonisjs/cli

Adonis v5 will deprecate the CLI, so you can use nodemon instead of the CLI. All you need to do is install it as a dev dependency.

$ npm i -D nodemon

and add a dev npm script

  "scripts": {
    "start": "node server.js",
    "test": "node ace test",
+ "dev": "nodemon"
  },

Redis

redis.png

We will use Redis to handle game state. Horizontal scaling is common in modern software development. We should be able to spin up and tear down servers at will. This means we shouldn't store state in our apps since instances will not be able to share state.

Redis will allow us to maintain the game state for millions of gamers. Redis offers faster read-write operations than a traditional DB, so it is ideal for the game.

Follow the instructions here to install Redis on your machine

What are web sockets?

According to Wikipedia

A WebSocket is a computer communications protocol, providing full-duplex communication channels over a single TCP connection.

A WebSocket is a communication channel that allows for bi-directional communication. Emissions and broadcasts replace the request-response mechanism in HTTP.

Screenshot from 2020-12-23 18-17-10.png

So a server can broadcast to several connected clients at the same time.

Screenshot from 2020-12-23 18-22-13.png

Clients emit information to the server without waiting for a response. So communication occurs through listeners set up by the client.

Project Architecture

The project follows this simple architecture

  • A user sets a username

set-username.gif

  • The user generates a game code

generate-game-code.gif

  • The user shares the game code with a friend

  • The friend sets a username and uses the game code to initialize the game

joining-game-with-code.gif

  • Both users are redirected to the game where they play in rounds

Getting Started

We won't go into the details of the markup and frontend Javascript. We will instead look into the implementation of Websockets in AdonisJS.

Step 1: Clone the repo

$ git clone https://github.com/vicradon/tic-tac-toe-tut

Step 2: Install dependencies

$ npm i

Step 3: Start the Redis server

$ redis-server

Step 3: In another terminal, start the Adonis server or nodemon

$ adonis serve --dev

# or
$ npm run dev

Now that everything is set up, we will look the logic in core parts of the game and learn more about WebSockets.

WebSockets in AdonisJS

AdonisJS is a node.js framework. Its websocket implementation uses class-based Controllers to handle logic. So we deal with controllers represented as classes.

class TicTacToeController {
  constructor({ socket, request }) {
    this.socket = socket;
    this.request = request;
  }
  async onClose(){
  }
}

Every connected client links to an instance of this class. So a client can communicate with other clients in the server environment. The connected client's socket is this.socket. We can also access the request object here using this.request. This means we can access cookies in the WebSocket Controller. This will prove useful as we go further.

// get all cookies
this.request.cookies()

Handling User Registration

To be able to identify a user, we set their username in an encrypted cookie during registration.

      // register method in UserController.js
      session.flash({ success: "Account created successfully" });
      response.cookie("username", username);

Since we didn't use session or JWT auth, this serves as a Psuedo-auth. So if the user reloads the browser, we return an authenticated view if the username cookie is present. An example of this is showing Hi {{username}} in home.edge.

@if(username)
<div class="col-md-6 mb-5">
  <h2>Hi {{ username }}</h2>
  ...
</div>
@else
  ...
@endif

The index method HomeController.js passes the variables in the home.edge view.

    // index method in HomeController.js
    const game_code = await Redis.hget(request.cookie("username"), "game_code");
    const username = request.cookie("username");
    return view.render("home", { game_code, username });

How to handle connected clients

When the home or game page loads, the browser tries to establish a connection with the server. An API call triggers the setSocketId method in UserController.js. This is where the gamer's socket-id is set to their created Redis map.

     const username = request.cookie("username");
     await Redis.hset(username, "socket_id", socket_id);

This happens every time the browser reloads, so the connection is always established.

Server Emissions

Events sent by your client to the server can trigger emissions to

  1. Yourself using this.socket.emit

  2. Several connected clients using this.socket.emitTo

  3. All other connected clients except yourself using this.socket.broadcast

  4. All connected clients including yourself using this.socket.broadcastToAll

This game utilizes 1 and 2. For example when player1 makes a mark ("X") on the board, they send the board's index. The server then emitsTo player2 with player1's mark ("X") and the board index.

        this.socket.emitTo(
          "otherPlayerMove",
          { cell, other_player_mark: current_player_mark },
          [`tic-tac-toe#${next_player_socket_id}`]
        );

This is an instance of where we can't rely on the client's innocence. We don't expect player1 not to change their mark from "X" to say "M". The client can do a lot to break our game so we must limit their power.

How player moves are handled.

When a player makes a move, we execute that turn's logic if the user's move is valid.

Turn Logic

Win State

The turn logic involves setting a player's mark on an array. The array's elements' indexes correspond to the game board.

      const board = JSON.parse(gameObject.board);
      const { mark: current_player_mark } = JSON.parse(
        gameObject[`${username}_stats`]
      );
      board[Number(cell)] = current_player_mark;

Then checking if the board has a valid win state

// inside the `executeTurnLogic` method
const { status, player, sequence } = this.checkForWinner(board);

In the checkForWinner method, we iterate through a set of winning sequences

    const winningSequences = [
      [0, 1, 2],
      [3, 4, 5],
      [6, 7, 8],
      [0, 3, 6],
      [1, 4, 7],
      [2, 5, 8],
      [0, 4, 8],
      [2, 4, 6],
    ];

Insert winning sequences images here

We represent the board cells which map to the sequence as boardSequence. For the first of the winning sequences, it will look like

[0, 1, 2]
["O", "X", "X"]

We check if all elements in the current sequence mapping have the same values, "O" or "X". If it is "X", player1 wins, else player2 wins.

    for (let sequence of winningSequences) {
      const boardSequence = [
        board[sequence[0]],
        board[sequence[1]],
        board[sequence[2]],
      ];
      const player1IsWinner = boardSequence.every((value) => value === "X");
      if (player1IsWinner) {
        return { status: "win", player: "player1", sequence };
      }

      const player2IsWinner = boardSequence.every((value) => value === "O");
      if (player2IsWinner) {
        return { status: "win", player: "player2", sequence };
      }
    }

Draw State

At the start of the game, the board is an 8 sized array of empty strings.

const board = ["", "", "", "", "", "", "", ""]

If at any point, no empty string exists in the array, the game results in a draw. Both players will be able to request a rematch.

// Example of draw state
["X", "O", "X", "X", "X", "O", "O", "X", "O"]

Client emissions

Since WebSockets support bidirectional communication, hence we can have simultaneous client-server communication. A client cannot emit events to other clients, it can only emit to the server which then emits to other clients.

Board moves on the client

When we emit a boardMove event to the server. The server has to figure out how to process this event and send a "response" to both players.

function makeMove(cell) {
  ws.getSubscription("tic-tac-toe").emit("boardMove", {
    cell,
  });
  updateTurnNotification(`It's ${other_player}'s turn`);
}

We can receive messages from the server by setting listeners like this.

  game.on("otherPlayerMove", (response) => {
    updateBoard(response);
    canMove = true;
    updateTurnNotification(`It's your turn`);
  });

We set up all the listeners in the subscribeToChannel function in game.js. These listeners are like JavaScript event listeners. They keep handling the events they are listening to even after registration.

Security Considerations

security-implications.jpg

Photo by John Salvino on Unsplash

Most of the game logic lives on the server. Every datastore on the client is mutable by javascript. Javascript can't read or change encrypted cookies, giving them the highest security.

Best practices when dealing with WebSockets

  1. Only use Websockets if HTTP, polling and Server-Sent Events (SSE) aren't suitable for the problem. For example, if you building a real-time dashboard with little or no events sent from the client, you are better off using SSE.

  2. Maintain state in a cache such as Redis or Memcache. You won't want to be making DB calls for time-sensitive operations.

Conclusion

In this tutorial, learned about the WebSocket protocol. We saw the details of building Tic-Tac-Toe using the technology. Finally, we learned the best practices to follow when building websocket applications.

References

  1. Wikipedia Article on WebSockets

  2. Mozilla Docs on the WebSocket API

  3. Internet Engineering Task Force (IETF) Websocket Protocol

More from this blog

O

Osinachi's base

66 posts

Hi there, I'm a software engineer that enjoys building stuff and talking about them. I also tinker a bit with hardware and robotics using Arduino and ROS.