Build a WebSockets Powered Tic-tac-toe with AdonisJS
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
Node.js
AdonisJS
Redis
Prerequisites
Node.js

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

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

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.

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

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

- The user generates a game code

The user shares the game code with a friend
The friend sets a username and uses the game code to initialize the game

- 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
Yourself using
this.socket.emitSeveral connected clients using
this.socket.emitToAll other connected clients except yourself using
this.socket.broadcastAll 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

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
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.
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.
