Build a Real-Time Multiplayer Tic-Tac-Toe Game Using WebSockets and Microservices
In this tutorial, we’ll build a real-time multiplayer Tic-Tac-Toe game using Node.js, Socket.IO, and Redis. This game allows two players to connect from different browser tabs, take turns playing, and see real-time updates as they play. We'll use Redis to manage game state synchronization across multiple WebSocket servers, making our application scalable.
By the end, you'll have a fully functional game with real-time capabilities and a solid understanding of how to use WebSockets and Redis to build scalable real-time applications.
What You Will Learn
How to use Socket.IO for real-time communication.
How to use Redis Pub/Sub to synchronize game state across multiple clients.
How to set up a scalable WebSocket server architecture.
Prerequisites
Before we start, make sure you have the following installed:
Node.js (v16 or higher)
Redis
Docker (optional, for running Redis in a container)
Basic knowledge of JavaScript, Node.js, and WebSockets.
Table of Contents
Project Overview
We'll build a real-time Tic-Tac-Toe game with the following features:
Two players can connect and play a game.
The game board updates in real-time across different browser tabs.
The game announces a winner or declares a draw when the board is full.
We’ll use:
Node.js with Socket.IO for handling WebSocket connections.
Redis Pub/Sub to manage game state synchronization across clients.
Step 1: Setting Up Your Development Environment
Installing Node.js
Ensure you have Node.js installed on your system:
node -v
If you don’t have it installed, download it from Node.js.
Installing Redis
You can install Redis locally or run it in a Docker container.
macOS (Using Homebrew)
First, ensure that you have Homebrew installed on your system before running the commands below:
brew install redis
brew services start redis
Verify that the Redis container is running with the following command:
redis-cli ping
You should see:
PONG
Using Docker to Run Redis
docker run --name redis-server -p 6379:6379 -d redis
Check if Redis is running using:
docker exec -it redis-server redis-cli ping
Step 2: Setting Up the Project
1. Create the Project Directory
mkdir tic-tac-toe
cd tic-tac-toe
npm init -y
2. Install Dependencies
npm install express socket.io redis dotenv
3. Create Environment Variables
Create a .env
file in your project root with the following contents:
PORT=3000
REDIS_HOST=localhost
REDIS_PORT=6379
Step 3: Implementing the WebSocket Server with Redis
In this step, we'll set up a WebSocket server that handles real-time game interactions using Node.js, Socket.IO, and Redis. This server will manage the game state, handle player moves, and ensure synchronization across multiple clients using Redis Pub/Sub.
We'll break down each section of the code so you understand exactly how everything fits together.Server Code Explanation
Create a file named server.js
and add the following code:
import dotenv from 'dotenv';
import express from 'express';
import http from 'http';
import { Server } from 'socket.io';
import { createClient } from 'redis';
dotenv.config(); // Load environment variables from .env file
const app = express();
const server = http.createServer(app);
const io = new Server(server, {
cors: {
origin: "http://localhost:5173",
methods: ["GET", "POST"],
}
});
dotenv: Loads environment variables from a
.env
file to keep sensitive information like ports and keys secure.express: Sets up a basic Express server to handle HTTP requests.
http: We create an HTTP server using Node's built-in
http
module, which we'll use with Socket.IO for WebSocket communication.Socket.IO: This library enables real-time, bidirectional communication between the server and clients.
CORS Configuration: Allows cross-origin requests from our frontend running on
localhost:5173
.
Then, to create Redis publisher and subscriber clients, we’ll add the following code to server.js
:
// Initialize Redis clients
const pubClient = createClient();
const subClient = createClient();
await pubClient.connect();
await subClient.connect();
We use Redis to handle real-time data synchronization between connected clients.
pubClient: Used to publish messages (like game state updates).
subClient: Subscribes to messages (listens for updates).
- connect(): Establishes a connection to the Redis server.
In this paradigm, one client is used to publish updates, and the other one subscribes to updates. This helps avoid blocking behavior, since Redis clients in subscribe mode can only receive messages.
To subscribe to Redis channels for game updates, we’ll add the following code to server.js
:
// Subscribe to the Redis channel for game updates
await subClient.subscribe('game-moves', (message) => {
gameState = JSON.parse(message);
io.emit('gameState', gameState);
});
subClient.subscribe: Listens for messages on the
game-moves
channel.Whenever a new move is made by a player, the game state is updated in Redis, and all connected clients are informed of the new state.
The
message
parameter contains the game state as a string. We parse it into a JavaScript object and broadcast the updated state using Socket.IO.
Next, to define the game state and functions, we’ll add the following code to server.js
:
// Define initial game state
let gameState = {
board: Array(9).fill(null),
xIsNext: true,
};
// Function to reset the game
function resetGame() {
gameState = {
board: Array(9).fill(null),
xIsNext: true,
};
}
gameState: Keeps track of the current state of the board and whose turn it is (
xIsNext
).The board is represented as an array of 9 cells (each can be 'X', 'O', or
null
).The
xIsNext
flag determines which player's turn it is.
resetGame(): Resets the board and turn indicator to their initial state, allowing for a new game to start.
Next, to handle WebSocket connections, let’s add the following code to server.js
:
io.on('connection', (socket) => {
console.log('New client connected:', socket.id);
// Send the current game state to the newly connected client
socket.emit('gameState', gameState);
The
io.on('connection')
event is triggered when a new client connects.socket.id: A unique identifier for each connected client.
We immediately send the current
gameState
to the new client so they can see the current board.
To handle player moves, we’ll add the following code to server.js
:
// Handle player moves
socket.on('makeMove', (index) => {
// Prevent making a move if cell is already taken or game is over
if (gameState.board[index] || calculateWinner(gameState.board)) return;
// Update the board and switch turns
gameState.board[index] = gameState.xIsNext ? 'X' : 'O';
gameState.xIsNext = !gameState.xIsNext;
// Publish the updated game state to Redis
pubClient.publish('game-moves', JSON.stringify(gameState));
io.emit('gameState', gameState);
});
makeMove: This event is triggered when a player clicks on a cell.
Validation: We check if the cell is already occupied or if the game has ended before making a move.
Updating Game State: If the move is valid, we update the board and switch turns.
The updated game state is then:
Published to Redis: This ensures that all instances of the server stay in sync.
Broadcasted to all clients: This immediately updates the game board for all players.
To handle game restarts, we’ll add the following code to server.js
:
// Handle game restarts
socket.on('restartGame', () => {
resetGame();
io.emit('gameState', gameState);
});
To handle client disconnection handling, we’ll add the following code to server.js
:
socket.on('disconnect', () => {
console.log('Client disconnected:', socket.id);
});
});
Finally, to process the logic of the game, we’ll add the following functions to server.js
:
// Function to check if there's a winner
function calculateWinner(board) {
const lines = [
[0, 1, 2], [3, 4, 5], [6, 7, 8],
[0, 3, 6], [1, 4, 7], [2, 5, 8],
[0, 4, 8], [2, 4, 6]
];
for (let [a, b, c] of lines) {
if (board[a] && board[a] === board[b] && board[a] === board[c]) {
return board[a];
}
}
return null;
}
function isBoardFull(board) {
return board.every((cell) => cell !== null);
}
calculateWinner(): Checks if there’s a winning combination on the board.
isBoardFull(): Checks if all cells are filled, indicating a draw.
Step 4: Implement the React Frontend interface
In this step, we build a simple and interactive React frontend for our Tic-Tac-Toe game. This frontend allows players to connect to the WebSocket server, make moves, and see the game board update in real-time.
In App.jsx
, add the following code:
import React, { useEffect, useState } from 'react';
import io from 'socket.io-client';
const socket = io('http://localhost:3000');
function App() {
const [gameState, setGameState] = useState({
board: Array(9).fill(null),
xIsNext: true,
winner: null
});
useEffect(() => {
socket.on('gameState', (state) => {
setGameState(state);
});
return () => socket.off('gameState');
}, []);
const handleClick = (index) => {
if (gameState.board[index] || gameState.winner) return;
socket.emit('makeMove', index);
};
const renderCell = (index) => (
<button onClick={() => handleClick(index)}>{gameState.board[index]}</button>
);
return (
<div>
<h1>Multiplayer Tic-Tac-Toe</h1>
<div className="board">
{[...Array(9)].map((_, i) => renderCell(i))}
</div>
<button onClick={() => socket.emit('restartGame')}>Restart Game</button>
</div>
);
}
export default App;
Here is a summary of how the React app is broken down:
WebSocket Connection:
- The frontend establishes a connection to the server using
socket.io-client
.
- The frontend establishes a connection to the server using
State Management:
The game state (
gameState
) is managed with React'suseState
and includes:The board (9 cells).
The flag xIsNext to indicate the current player's turn.
The winner status.
Real-Time Updates:
The
useEffect
hook:Listens for
gameState
updates from the server.Updates the local game state when changes are detected.
Cleans up the WebSocket listener when the component is unmounted.
Handling Player Moves:
The
handleClick
function:Checks if a cell is already occupied or if the game has a winner before allowing a move.
Sends a
makeMove
event to the server with the clicked cell index.
Game Board Rendering:
The
renderCell
function creates a button for each cell on the board.The board is displayed using a 3x3 grid.
Restart Game:
- The "Restart Game" button emits a
restartGame
event to reset the game board for all players.
- The "Restart Game" button emits a
User Interface:
- A simple and interactive layout that allows players to take turns and see updates in real-time.
Step 5: Running the Application
Starting the Backend
To start the backend server, open a new terminal window and run the following commands:
cd tic-tac-toe
npm start
Starting the Frontend
To start the React frontend server, open a new terminal window and run the commands below (do not use the same one which the backend server is running on, as you need both running simultaneously to run the game).
cd tic-tac-toe-client
npm run dev
Accessing the Game
Open your browser and navigate to:
http://localhost:5173
Step 6: Viewing Redis Messages in Real-Time
While the game is running, you can view Redis messages to see real-time game state updates.
Open a terminal and run:
redis-cli
SUBSCRIBE game-moves
This will display game updates:
1) "message"
2) "game-moves"
3) "{\"board\":[\"X\",null,\"O\",null,\"X\",null,null,null,null],\"xIsNext\":false}"
Every time a move is made or the game state changes, the server publishes the updated game state to the game-moves
channel. Using redis-cli
, you can monitor these updates in real-time, as the game is being played.
Demo
In this demo, you'll see the Tic Tac Toe game running locally, demonstrating real-time updates as players take turns.
The gameplay showcases features such as turn switching, board updates, and game state announcements (winner or draw). This highlights how the game leverages WebSocket communication to provide a smooth, interactive experience.
Conclusion
Congratulations, you’ve successfully built a real-time multiplayer Tic-Tac-Toe game using Node.js, Socket.IO, and Redis. Here’s what you’ve learned:
Real-time WebSocket communication using Socket.IO.
Game state management using Redis Pub/Sub.
Building a responsive front-end with React.
Next Steps
Add player authentication.
Implement a chat feature.
Deploy your application to a cloud provider for scalability.
Happy coding!
Subscribe to my newsletter
Read articles from Birks Sachdev directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
Birks Sachdev
Birks Sachdev
Software Engineer at Salesforce