Day 3: Building a Customizable Tic Tac Toe with Socket.io - Set Your Own Board Size and Play Online!
Welcome back to the development journey of our Tic-Tac-Toe game! In Day 2, we focused on implementing the server-side logic to make real-time multiplayer possible. Today, I’ll be diving deeper into the client-side, where I’ve added some important upgrades, including the Connect & Play feature. If you missed the second blog, make sure to check it out, as it covers the server-side implementation that powers today’s new functionality. And, if you're just joining, don't forget to catch up on Day 1, where I’ve introduced dynamic board sizes and the Pass & Play feature.
Alright, let’s break down the major upgrades I’ve made to the useTicTacToe
custom hook on Day 3. Here’s what’s new and how each part works in simple terms:
Major Upgrades to the useTicTacToe
Custom Hook
1. Setting Up Socket.IO Listeners for Real-Time Events
With Socket.IO, I created several listeners for real-time events like player turns, board resets, and handling room exits.
Player Turn Handling: I used the
socket.on("success-player-turn", playerTurnFn)
event to listen for moves made by the opponent. When a move is received, the board updates and switches turns.Opponent Joining and Leaving Room: When a player joins,
opponentJoinedFn
triggers a toast message confirming that both players are ready. TheleaveRoomFn
event alerts the other player if their opponent leaves the room and redirects them to the main page.Error Handling: If there’s an invalid move (like selecting an occupied cell),
playerTurnErrFn
shows a toast error message.
useEffect(()=>{
if(gameMode==="offline") return;
function playerTurnFn(newBoard) {
setBoard(newBoard);
setIsX(prev => !prev);
}
function playerTurnErrFn(errMsg) {
toast.error(errMsg, toastOptionsObj);
}
function leaveRoomFn() {
toast.error("Opponent left the room", toastOptionsObj);
navigate("/");
}
function resetBoardFn() {
setBoard(initializeBoard(boardSize));
setIsX(true);
}
function opponentJoinedFn() {
toast.success("Opponent joined the room, now you can start playing", toastOptionsObj);
}
socket.on("success-player-turn", playerTurnFn);
socket.on("opponent-leaved-room", leaveRoomFn);
socket.on("success-reset-board", resetBoardFn);
socket.on("opponent-joined-room", opponentJoinedFn);
socket.on("error-player-turn", playerTurnErrFn);
return () => {
socket.off("success-player-turn", playerTurnFn);
socket.off("opponent-leaved-room", leaveRoomFn);
socket.off("success-reset-board", resetBoardFn);
socket.off("opponent-joined-room", opponentJoinedFn);
socket.off("error-player-turn", playerTurnErrFn);
};
}, []);
2. Handling Unload Events
To ensure a smooth experience, I added event listeners to handle cases when a player refreshes or leaves the game window.
Before Unload: When a player leaves or refreshes,
socket.emit("leave-room", state?.roomId)
sends a notification to the other player, so they know the game has ended.On Load: If a player tries to re-enter, they’re redirected to the main page to prevent session conflicts.
useEffect(()=>{
function handleUnload() {
socket.emit("leave-room", state?.roomId);
}
function handleLoad() {
navigate("/");
}
if(gameMode==="online") window.addEventListener("beforeunload", handleUnload);
window.addEventListener("load", handleLoad);
return () => {
if(gameMode==="online") window.removeEventListener("beforeunload", handleUnload);
window.removeEventListener("load", handleLoad);
};
}, []);
3. Game Modes : "Pass & Play or Connect & Play?"
Next, I split the handleClick
function to accommodate both Pass & Play and Connect & Play modes.
Pass & Play: If the game is offline, moves are handled locally by updating the board and switching turns.
Connect & Play: If playing online, the code checks if the current player’s marker matches their turn. If yes, it updates the board and sends the move to the opponent.
function passAndPlay(index) {
if (calculateWinner()) return;
if (board[index]) return;
const newBoard = [...board];
newBoard[index] = isX ? "X" : "O";
setBoard(newBoard);
setIsX(!isX);
}
function connectAndPlay(index) {
const { playerMarker, roomId } = state;
if (isX && playerMarker === "X" || !isX && playerMarker === "O") {
if (calculateWinner()) return;
if (board[index]) return;
const newBoard = [...board];
newBoard[index] = playerMarker;
socket.emit("player-turn", newBoard, roomId);
}
}
function handleClick(index) {
if (gameMode === "offline") passAndPlay(index);
else connectAndPlay(index);
}
4. handleBoardReset
– Resetting the Game Board
The handleBoardReset
function is responsible for resetting the game board to its initial state when the user decides to start a new game.
Local Board Reset: It first calls
intializeBoard(boardSize)
to generate a fresh board based on the current size and updates theboard
state usingsetBoard
. The game also resets the player turn by settingsetIsX(true)
, meaning "X" always starts the new game.Online Mode Sync: If the game mode is online, it emits a
reset-board
event to the server (socket.emit("reset-board", state.roomId)
) to let the other player know that the board has been reset, ensuring both players' boards are in sync.
function handleBoardReset() {
setBoard(initializeBoard(boardSize));
setIsX(true);
if (gameMode === "online") socket.emit("reset-board", state.roomId);
}
5. copyRoomId
– Copying Room ID to Clipboard
The copyRoomId
function allows players to easily copy the room ID to their clipboard, making it convenient for them to share the room with others and invite a friend to join the game.
Clipboard Copy:
navigator.clipboard.writeText(state?.roomId)
writes the room ID to the user's clipboard so they can easily share it.UI Feedback: After copying, the text of the button changes to "Copied" as feedback for the user that the action was successful. This text reverts back to "Copy Id" after 500ms using
setTimeout()
. This gives a small visual cue to the user that the action was completed.
function copyRoomId(e){
navigator.clipboard.writeText(state?.roomId)
e.target.textContent="Copied"
setTimeout(()=>{
e.target.textContent="Copy Id"
},500)
}
6. handleLeave
– Leaving the Game
The handleLeave
function is triggered when a player decides to leave the game, either voluntarily or due to some other reason.
Notifying Server: It emits a
leave-room
event (socket.emit("leave-room", state?.roomId)
) to the server. This tells the server that the current player is leaving, allowing the server to handle the removal of that player from the game session.Redirect to Main Page: After sending the leave notification, the user is redirected to the home page using
navigate("/")
, which ensures that the player exits the game session and returns to the main lobby or game entry point.
function handleLeave(){
socket.emit("leave-room",state?.roomId)
navigate("/")
}
Full Updated Code for useTicTacToe
Hook
Here's the complete code for the updated useTicTacToe
hook with Connect & Play
functionality:
import { useContext, useEffect, useMemo, useState } from "react"
import intializeBoard from "../utils/initializeBoard"
import generateWinningPatterns from "../utils/generateWinningPatterns"
import { useLocation, useNavigate } from "react-router-dom"
import { SocketContext } from "../context/SocketProvider"
import { toast } from "react-toastify"
import { toastOptionsObj } from "../utils/constant"
function useTicTacToe(boardSize,gameMode){
const [board,setBoard]=useState(intializeBoard(boardSize))
const [isX,setIsX]=useState(true)
//State and values used for connect and play functionality
const {state}=useLocation()
const socket=useContext(SocketContext)
const navigate=useNavigate()
const winningPatterns=useMemo(()=>generateWinningPatterns(boardSize),[])
useEffect(()=>{
if(gameMode==="offline")return
function playerTurnFn(newBoard){
setBoard(newBoard)
setIsX(prev=>!prev)
}
function playerTurnErrFn(errMsg){
toast.error(errMsg,toastOptionsObj)
}
function leaveRoomFn(){
toast.error("Opponent left the room",toastOptionsObj)
navigate("/")
}
function resetBoardFn(){
setBoard(intializeBoard(boardSize))
setIsX(true)
}
function opponentJoinedFn(){
toast.success("Opponent joined the room, now you can start playing",toastOptionsObj)
}
socket.on("success-player-turn",playerTurnFn)
socket.on("opponent-leaved-room",leaveRoomFn)
socket.on("success-reset-board",resetBoardFn)
socket.on("opponent-joined-room",opponentJoinedFn)
socket.on("error-player-turn",playerTurnErrFn)
return function(){
socket.off("success-player-turn",playerTurnFn)
socket.off("opponent-leaved-room",leaveRoomFn)
socket.off("success-reset-board",resetBoardFn)
socket.off("opponent-joined-room",opponentJoinedFn)
socket.off("error-player-turn",playerTurnErrFn)
}
},[])
useEffect(()=>{
function handleUnload(){
socket.emit("leave-room",state?.roomId)
}
function handleLoad(){
navigate("/")
}
if(gameMode==="online")window.addEventListener("beforeunload",handleUnload)
window.addEventListener("load",handleLoad)
return ()=>{
if(gameMode==="online")window.removeEventListener("beforeunload",handleUnload)
window.removeEventListener("load",handleLoad)
}
},[])
function calculateWinner(){
for(let i=0;i<winningPatterns.length;i++){
let winnerFlag=true
for(let j=0;j<winningPatterns[i].length-1;j++){
if(!board[winningPatterns[i][j]]){
winnerFlag=false
break
}
if(board[winningPatterns[i][j]]!==board[winningPatterns[i][j+1]]){
winnerFlag=false
break
}
}
if(winnerFlag)return board[winningPatterns[i][0]]
}
return null
}
function statusMessage(){
const winner=calculateWinner()
if(winner)return `Player ${winner} is winner`
if(!board.includes(null))return "Game is draw"
return `Player ${isX ? "X" : "O"} turn`
}
function passAndPlay(index){
if(calculateWinner())return
if(board[index])return
const newBoard=[...board]
newBoard[index]=isX ? "X" : "O"
setBoard(newBoard)
setIsX(!isX)
}
function connectAndPlay(index){
const {playerMarker,roomId}=state
if(isX && playerMarker==="X" || !isX && playerMarker==="O" ){
if(calculateWinner())return
if(board[index])return
const newBoard=[...board]
newBoard[index]=playerMarker
socket.emit("player-turn",newBoard,roomId)
}
}
function handleClick(index){
if(gameMode==="offline")passAndPlay(index)
else connectAndPlay(index)
}
function handleBoardReset(){
setBoard(intializeBoard(boardSize))
setIsX(true)
if(gameMode==="online")socket.emit("reset-board",state.roomId)
}
function copyRoomId(e){
navigator.clipboard.writeText(state?.roomId)
e.target.textContent="Copied"
setTimeout(()=>{
e.target.textContent="Copy Id"
},500)
}
function handleLeave(){
socket.emit("leave-room",state?.roomId)
navigate("/")
}
return {board,statusMessage,handleClick,copyRoomId,handleLeave,handleBoardReset,state}
}
export default useTicTacToe
Adding Room Management for Online Gameplay: The RoomDialog Component
In the previous blogs, I’ve focused on setting up the core gameplay for Tic-Tac-Toe, but with the introduction of the "Connect & Play" feature, I’ve added a new component—RoomDialog—to manage room creation and joining between players. This dialog box serves as a user interface where players can either create a new room or join an existing room, providing a seamless connection for online gameplay.
Here’s how this component works and the different parts that make it run smoothly:
1. State Management:
isJoin
: This state controls whether the dialog box is for joining an existing room or creating a new one. It toggles betweentrue
(for joining) andfalse
(for creating).roomIdRef
: This is a ref used to get the value of the room ID input field, which the user uses to either join an existing room or create a new one.
2. Redux State:
boardSize
: It uses Redux to get the current board size (boardSize
) from thegameOptionReducer
. This value is used when creating a new room to set the board size.
3. Socket Context:
socket
: TheuseContext
hook is used to access the SocketContext. This provides access to the Socket.IO connection for real-time communication, allowing users to join rooms, create rooms, and handle events such as errors or successful connections.
4. Navigation:
navigate
: TheuseNavigate
hook is used to programmatically redirect the user to the/board
route after successfully joining or creating a room.
5. Effect Hook for Socket Events:
The useEffect
hook sets up Socket.IO event listeners to handle room join and creation events. Here's what each one does:
errorFn
: Handles error messages when a player fails to join or create a room. It displays the error in a toast notification.successFn
: Handles successful room joins or creations.It receives two parameters:
playerMarker
(indicating the player’s marker, "X" or "O") andboardSize
.If
playerMarker
is "O" (meaning the current player joins as the second player), it dispatches an action to update the board size in the Redux store, ensuring the correct game settings are applied.Finally, it navigates the player to the
/board
page usingnavigate()
, sending along theroomId
(retrieved fromroomIdRef.current.value
) and theplayerMarker
in the state. This ensures the player enters the game board with the correct marker and room information for real-time gameplay.This setup supports the room synchronization and the correct initial setup for each player in Connect & Play mode.
useEffect(() => {
function errorFn(errMsg) {
toast.error(errMsg, toastOptionsObj);
}
function successFn(playerMarker, boardSize) {
if (playerMarker === "O") dispatch(setBoardSize(+boardSize));
navigate("/board", { state: { roomId: roomIdRef.current.value, playerMarker } });
}
socket.on("error-join-room", errorFn);
socket.on("success-join-room", successFn);
socket.on("error-create-room", errorFn);
socket.on("success-create-room", successFn);
return () => {
socket.off("error-join-room", errorFn);
socket.off("success-join-room", successFn);
socket.off("error-create-room", errorFn);
socket.off("success-create-room", successFn);
};
}, []);
errorFn
andsuccessFn
are registered when the component is mounted and removed when it's unmounted to ensure there are no memory leaks.
6. Handle Actions:
handleClose
: This function closes the dialog and resets the game mode by dispatching thesetGameMode("")
action. This can be triggered when the user clicks the close button in the dialog.
function handleClose() {
dispatch(setGameMode(""));
}
handleClick
: This function handles the button click event when the user either joins an existing room or creates a new room.If the room ID is empty, an error toast is shown.
If the socket is disconnected, an error toast is shown.
If joining a room (i.e.,
isJoin === true
), it emits ajoin-room
event to the server with the room ID.If creating a room (i.e.,
isJoin === false
), it emits acreate-room
event to the server with the room ID and the selected board size.
function handleClick() {
const roomId = roomIdRef.current.value;
if (!roomId) return toast.error("Please enter room id", toastOptionsObj);
if (!socket.connected) return toast.error("Check internet connection!", toastOptionsObj);
if (isJoin) return socket.emit("join-room", roomId);
socket.emit("create-room", roomId, boardSize);
}
- Room ID Generation: When the user is creating a new room, they can also generate a random room ID by clicking on the "Generate random room id" link, which uses the
crypto.randomUUID()
method.
function generateRandomRoomId() {
roomIdRef.current.value = crypto.randomUUID().slice(0, 8);
}
7. UI Components:
Dialog Layout:
The dialog has a header with two options: "Join Room" or "Create Room". The
isJoin
state controls which one is active (styled with a different background color).Input field for entering the room ID (for both joining and creating).
Button to either "Join" or "Create" based on the
isJoin
state.If the user is not joining, the option to generate a random room ID is displayed.
<div className="room-dialog-header">
<div className="first-child" onClick={() => setIsJoin(true)} style={isJoin ? { backgroundColor: "rgb(20,20,20)", color: "white" } : {}}>Join Room</div>
<div onClick={() => setIsJoin(false)} style={!isJoin ? { backgroundColor: "rgb(20,20,20)", color: "white" } : {}}>Create Room</div>
</div>
Full Code Snippet for RoomDialog Component:
import { useDispatch, useSelector } from "react-redux"
import "./roomDialog.css"
import { setBoardSize, setGameMode } from "../../store/gameOptionSlice"
import { useContext, useEffect, useState,useRef } from "react"
import { SocketContext } from "../../context/SocketProvider"
import { toast } from "react-toastify"
import { toastOptionsObj } from "../../utils/constant"
import { useNavigate } from "react-router-dom"
function RoomDialog(){
const dispatch=useDispatch()
const {boardSize}=useSelector(store=>store.gameOptionReducer)
const [isJoin,setIsJoin]=useState(true)
const roomIdRef=useRef()
const socket=useContext(SocketContext)
const navigate=useNavigate()
useEffect(()=>{
function errorFn(errMsg){
toast.error(errMsg,toastOptionsObj)
}
function successFn(playerMarker,boardSize){
if(playerMarker==="O")dispatch(setBoardSize(+boardSize))
navigate("/board",{state:{roomId:roomIdRef.current.value,playerMarker}})
}
socket.on("error-join-room",errorFn)
socket.on("success-join-room",successFn)
socket.on("error-create-room",errorFn)
socket.on("success-create-room",successFn)
return ()=>{
socket.off("error-join-room",errorFn)
socket.off("success-join-room",successFn)
socket.off("error-create-room",errorFn)
socket.off("success-create-room",successFn)
}
},[])
function handleClose(){
dispatch(setGameMode(""))
}
function handleClick(){
const roomId=roomIdRef.current.value
if(!roomId)return toast.error("Please enter room id",toastOptionsObj)
if(!socket.connected)return toast.error("Check internet connection !",toastOptionsObj)
if(isJoin)return socket.emit("join-room",roomId)
socket.emit("create-room",roomId,boardSize)
}
return(
<div className="overlay-container">
<button className="close-dialog" onClick={handleClose}>X</button>
<div className="room-dialog-container">
<div className="room-dialog-header">
<div className="first-child" onClick={()=>setIsJoin(true)} style={isJoin ? {backgroundColor:"rgb(20,20,20)",color:"white"} : {}}>Join Room</div>
<div onClick={()=>setIsJoin(false)} style={!isJoin ? {backgroundColor:"rgb(20,20,20)",color:"white"} : {}}>Create Room</div>
</div>
<div className="room-dialog-content">
<input type="text" ref={roomIdRef} className="content-width" placeholder={isJoin ? "Enter Room ID to join" :"Enter Room ID to create a new room"} />
<button className="content-width" onClick={handleClick}>{isJoin ? "Join" : "Create"}</button>
{!isJoin && <p onClick={()=>roomIdRef.current.value=crypto.randomUUID().slice(0,8)}>Generate random room id</p>}
</div>
</div>
</div>
)
}
export default RoomDialog
Conclusion
To wrap up Day 3, we focused on adding the Connect & Play
functionality (Client-side
) for the Tic Tac Toe game. We enhanced the existing Tic-Tac-Toe
custom hook to efficiently handle multiplayer functionality, including managing player turns, moves, and win conditions in real-time. Then, we built the RoomDialog
component, allowing players to either join an existing room or create a new one. This dialog integrates with socket events to manage errors and successes, ensuring seamless connections between players for multiplayer gameplay.
Looking ahead to Day 4, I’ll update the GameBoard component to incorporate two additional buttons for the "Connect & Play" functionality. Additionally, I’ll implement a new custom hook, useScreenSize
, which will make the game board responsive across different devices. This will ensure the Tic Tac Toe
grid adjusts smoothly for any screen size, from desktops to mobile devices, significantly improving the user experience for all players.
Subscribe to my newsletter
Read articles from Tanmay Bhansali directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by