Day 2: Building a Customizable Tic Tac Toe with Socket.io - Set Your Own Board Size and Play Online!
Welcome to Day 2 of building our customizable Tic Tac Toe game! After setting up the Pass & Play mode, I’m diving into Connect & Play — a feature that lets players connect remotely and play in real-time from separate devices. Today’s focus is on implementing the server-side logic with Express and Socket.IO to enable smooth, synchronized gameplay between players, regardless of their location. This will allow users to challenge friends across the internet, while still enjoying the flexibility of customizable grid sizes, just like in Pass & Play. I’ll break down each part of the server-side code so you can understand how it all works together to deliver a seamless, real-time experience. If you missed Day 1, where I built the Pass & Play mode with customizable grid sizes, check it out here to get caught up on the basics. By the end of today’s post, you’ll see how I’ve integrated everything on the server side to take this game to the next level!
1. Setting Up Server and Socket.IO
First, we import the necessary modules and initialize our server:
const express = require("express");
const { createServer } = require("http");
const { Server } = require("socket.io");
require("dotenv").config();
Express: We use Express to create the HTTP server.
Socket.IO: This allows for real-time communication between the client and server.
dotenv: We load environment variables for better security and flexibility, like the client URL and server port.
Then we initialize the server and configure CORS to allow communication with our client app:
const server = express();
const httpServer = createServer(server);
const io = new Server(httpServer, { cors: { origin: process.env.CLIENT_URL } });
Key Configuration
- The
Server
instance useshttpServer
and is set up to allow requests from the client URL defined in our.env
file.
2. Managing Room Data
We create an in-memory object roomData
to store details for each room, like the board size and connected players:
const roomData = {};
3. Socket.IO Connection
Once a client connects, we set up multiple Socket.IO event listeners for game functionality.
io.on("connection", (socket) => { ... });
A. Creating a Room
Players can create a room using the create-room
event, which takes roomId
and boardSize
as arguments:
socket.on("create-room", (roomId, boardSize) => {
try {
if (roomData[roomId]) throw new Error("Room id is already in use");
roomData[roomId] = { players: [socket.id], boardSize };
socket.join(roomId);
socket.emit("success-create-room", "X");
} catch (err) {
socket.emit("error-create-room", err.message);
}
});
Explanation:
Checks if the room already exists: If so, we send an error message.
Initializes the room: Adds the creator as the first player (
X
), stores the board size, and joins the room.Confirms room creation: Sends
"X"
to indicate that the creator will use "X" as their marker.
B. Joining a Room
The join-room
event lets players join an existing room by roomId
.
socket.on("join-room", (roomId) => {
try {
if (!roomData[roomId]) throw new Error("Room doesn't exist");
if (roomData[roomId].players?.length >= 2) throw new Error("Room is full");
roomData[roomId].players.push(socket.id);
socket.join(roomId);
socket.emit("success-join-room", "O", roomData[roomId]?.boardSize);
socket.to(roomId).emit("opponent-joined-room");
} catch (err) {
socket.emit("error-join-room", err.message);
}
});
Explanation:
Room and player checks: We validate that the room exists and is not full.
Updates room data: Adds the joining player as
O
and sends confirmation.Notifies opponent: Sends a message to the existing player, indicating their opponent has joined.
C. Handling Player Turns
The player-turn
event broadcasts the updated board state to both players after a move:
socket.on("player-turn", (newBoard, roomId) => {
try {
if (roomData[roomId].players.length <= 1) throw new Error("Wait for other player to join");
io.sockets.in(roomId).emit("success-player-turn", newBoard);
} catch (err) {
socket.emit("error-player-turn", err.message);
}
});
Explanation:
Room Validation: We ensure that both players have joined the room before allowing moves.
Broadcast Move: If valid, the server emits the updated board to both players in the room, keeping them in sync.
D. Leaving a Room
If a player leaves (intentionally or by closing the browser), we clear the room data and inform the remaining player:
socket.on("leave-room", (roomId) => {
try {
delete roomData[roomId];
socket.to(roomId).emit("opponent-leaved-room");
io.socketsLeave(roomId);
} catch (err) {
console.log(err.message);
}
});
Explanation:
Delete Room Data: We remove the room from
roomData
, so no new players can join this room.Notify Opponent: The other player receives a message that the opponent has left.
Removes all players: Uses
io.socketsLeave(roomId)
to clear the room.
E. Resetting the Board
The reset-board
event allows players to reset the game board without leaving the room.
socket.on("reset-board", (roomId) => {
try {
socket.to(roomId).emit("success-reset-board");
} catch (err) {
console.log(err.message);
}
});
Explanation:
- Resets the board: This sends a reset signal to the other player, resetting the game board on both clients.
4. Starting the Server
Finally, we start the Socket.IO server and bind it to the port specified in .env
.
io.listen(process.env.PORT);
Full Code Snippet
Here's the entire code for the Tic Tac Toe server with Connect & Play functionality:
const express = require("express");
const { createServer } = require("http");
const { Server } = require("socket.io");
require("dotenv").config();
const server = express();
const httpServer = createServer(server);
const io = new Server(httpServer, { cors: { origin: process.env.CLIENT_URL } });
const roomData = {};
io.on("connection", (socket) => {
socket.on("join-room", (roomId) => {
try {
if (!roomData[roomId]) throw new Error("Room doesn't exist");
if (roomData[roomId].players?.length >= 2) throw new Error("Room is full");
roomData[roomId].players.push(socket.id);
socket.join(roomId);
socket.emit("success-join-room", "O", roomData[roomId]?.boardSize);
socket.to(roomId).emit("opponent-joined-room");
} catch (err) {
socket.emit("error-join-room", err.message);
}
});
socket.on("create-room", (roomId, boardSize) => {
try {
if (roomData[roomId]) throw new Error("Room id is already in use");
roomData[roomId] = { players: [socket.id], boardSize };
socket.join(roomId);
socket.emit("success-create-room", "X");
} catch (err) {
socket.emit("error-create-room", err.message);
}
});
socket.on("player-turn", (newBoard, roomId) => {
try {
if (roomData[roomId].players.length <= 1) throw new Error("Wait for other player to join");
io.sockets.in(roomId).emit("success-player-turn", newBoard);
} catch (err) {
socket.emit("error-player-turn", err.message);
}
});
socket.on("leave-room", (roomId) => {
try {
delete roomData[roomId];
socket.to(roomId).emit("opponent-leaved-room");
io.socketsLeave(roomId);
} catch (err) {
console.log(err.message);
}
});
socket.on("reset-board", (roomId) => {
try {
socket.to(roomId).emit("success-reset-board");
} catch (err) {
console.log(err.message);
}
});
});
io.listen(process.env.PORT);
Conclusion
And that’s it! This server is ready to handle all essential actions for a real-time, multiplayer Tic Tac Toe game, ensuring smooth gameplay whether players are joining, making moves, or leaving the room.
With these server-side features, players are always in sync and informed, no matter where they play from.
Subscribe to my newsletter
Read articles from Tanmay Bhansali directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by