Convert your Tic Tac Toe to a multiplayer game using WebSocket

·

8 min read

First, we need to build a simple Tic-Tac-Toe game in React. There are many examples on the internet you can get. You can also use the one I prepared here.

Run the React app in the terminal:

yarn start

Now that we have our offline game running, it's time to convert it to a multiplayer online game.

Socket.IO

In this project we are going to use the Socket.IO API. What I like about it is that it resorts to other features such as HTTP long-polling when WebSocket is not achievable in the browser. So we won't have to reinvent the wheel.

Let's add the client package:

yarn add socket.io-client

In our App.js file, let's import the package then make our connection:

...

import io from 'socket.io-client';
const socket = io('http://localhost:4000');

function App() {
...
}

localhost:4000 is the Node server that we will create.

Let's create the server by starting up a new Express project.

npm init
npm i express socket.io

Create an index.js file and write a basic Express server for now.

const express = require('express')
const dotenv = require('dotenv')
dotenv.config()

const PORT = 4000;
const INDEX = '/index.html';

const app = express()
app.use((_req, res) => res.sendFile(INDEX, { root: __dirname }))

const server = app.listen(PORT, () => console.log(`Listening on http://localhost:${PORT}...`));

You can get the finished server here.

Since we will be converting it to a multiplayer game, we need to consider some things:

  • Determine who goes to turn first
  • Alternate between Player 1's and Player 2's turn
  • Determine if it is X or O that is currently in play

Back to our React app, let's prepare some additional states:

const [myTurn, setMyTurn] = useState(true);
const [xo, setXO] = useState('X');
const [player, setPlayer] = useState('');
const [hasOpponent, setHasOpponent] = useState(false);
const [share, setShare] = useState(false);
const [turnData, setTurnData] = useState(false);

We will change the turn() function a little:

const turn = (index) => {
    if (!game[index] && !winner && myTurn && hasOpponent) {
      socket.emit('reqTurn', JSON.stringify({ index, value: xo, room }));
    }
};

Before, we change the state directly every turn. But now, we cannot do that because we have to let the other player know that you have made your turn and vice versa. So we will send the data to the server first using socket.emit() then create a listener to receive that data. That's when we will change the state accordingly.

In our Node server, let's add the Socket server in the index.js:

...
const server = app.listen(PORT, () => console.log(`Listening on http://localhost:${PORT}...`));

// socket server
const socket = require('socket.io');
const io = socket(server, {
    cors: {
        origin: 'http://localhost:3000'
    }
});

io.on('connection', (socket) => {
    socket.on('reqTurn', (data) => {
        const room = JSON.parse(data).room
        io.to(room).emit('playerTurn', data)
    })

    socket.on('create', room => {
        socket.join(room)
    })

    socket.on('join', room => {
        socket.join(room)
        io.to(room).emit('opponent_joined')
    })

    socket.on('reqRestart', (data) => {
        const room = JSON.parse(data).room
        io.to(room).emit('restart')
    })
});

Notice the reqTurn listener. It just emits the data back to the client. The only difference is that it passes the data to a given room so that the data can only be received by the clients who joined that room. The same goes for the other listeners.

Let's go back to the React app and create the listeners for the client.

useEffect(() => {
    socket.on('playerTurn', (json) => {
      setTurnData(json);
    });

    socket.on('restart', () => {
      restart();
    });

    socket.on('opponent_joined', () => {
      setHasOpponent(true);
      setShare(false);
    });
}, []);

useEffect(() => {
    if (turnData) {
      const data = JSON.parse(turnData);
      let g = [...game];
      if (!g[data.index] && !winner) {
        g[data.index] = data.value;
        setGame(g);
        setTurnNumber(turnNumber + 1);
        setTurnData(false);
        setMyTurn(!myTurn);
        setPlayer(data.value);
      }
    }
}, [turnData, game, turnNumber, winner, myTurn]);

Now that the listeners are set and the game is ready for playing, let's write the code for allowing another user to join the game.

...
import { useLocation } from 'react-router';

...

const location = useLocation();
const params = new URLSearchParams(location.search);
const paramsRoom = params.get('room');
const [room, setRoom] = useState(paramsRoom);

...

useEffect(() => {
    if (paramsRoom) {
      // means you are player 2
      setXO('O');
      socket.emit('join', paramsRoom);
      setRoom(paramsRoom);
      setMyTurn(false);
    } else {
      // means you are player 1
      const newRoomName = random();
      socket.emit('create', newRoomName);
      setRoom(newRoomName);
      setMyTurn(true);
    }
}, [paramsRoom]);

Let's do some finishing touches to the JSX so that we can show and share the link.

return (
    <div className="container">
      Room: {room}
      <button className="btn" onClick={() => setShare(!share)}>
        Share
      </button>
      {share ? (
        <>
          <br />
          <br />
          Share link: <input type="text" value={`${window.location.href}?room=${room}`} readOnly />
        </>
      ) : null}
      <br />
      <br />
      Turn: {myTurn ? 'You' : 'Opponent'}
      <br />
      {hasOpponent ? '' : 'Waiting for opponent...'}
      <p>
        {winner || turnNumber === 9 ? (
          <button className="btn" onClick={sendRestart}>
            Restart
          </button>
        ) : null}
        {winner ? <span>We have a winner: {player}</span> : turnNumber === 9 ? <span>It's a tie!</span> : <br />}
      </p>
      <div className="row">
        <Box index={0} turn={turn} value={game[0]} />
        <Box index={1} turn={turn} value={game[1]} />
        <Box index={2} turn={turn} value={game[2]} />
      </div>
      <div className="row">
        <Box index={3} turn={turn} value={game[3]} />
        <Box index={4} turn={turn} value={game[4]} />
        <Box index={5} turn={turn} value={game[5]} />
      </div>
      <div className="row">
        <Box index={6} turn={turn} value={game[6]} />
        <Box index={7} turn={turn} value={game[7]} />
        <Box index={8} turn={turn} value={game[8]} />
      </div>
    </div>
);

The final React code will look like this:

import { useEffect, useState } from 'react';
import { useLocation } from 'react-router';
import './App.css';

import io from 'socket.io-client';
const socket = io('http://localhost:4000');

function App() {
  const [game, setGame] = useState(Array(9).fill(''));
  const [turnNumber, setTurnNumber] = useState(0);
  const [myTurn, setMyTurn] = useState(true);
  const [winner, setWinner] = useState(false);
  const [xo, setXO] = useState('X');
  const [player, setPlayer] = useState('');
  const [hasOpponent, setHasOpponent] = useState(false);
  const [share, setShare] = useState(false);
  const [turnData, setTurnData] = useState(false);

  const location = useLocation();
  const params = new URLSearchParams(location.search);
  const paramsRoom = params.get('room');
  const [room, setRoom] = useState(paramsRoom);


  const turn = (index) => {
    if (!game[index] && !winner && myTurn && hasOpponent) {
      socket.emit('reqTurn', JSON.stringify({ index, value: xo, room }));
    }
  };

  const sendRestart = () => {
    socket.emit('reqRestart', JSON.stringify({ room }));
  };

  const restart = () => {
    setGame(Array(9).fill(''));
    setWinner(false);
    setTurnNumber(0);
    setMyTurn(false);
  };

  useEffect(() => {
    combinations.forEach((c) => {
      if (game[c[0]] === game[c[1]] && game[c[0]] === game[c[2]] && game[c[0]] !== '') {
        setWinner(true);
      }
    });

    if (turnNumber === 0) {
      setMyTurn(xo === 'X' ? true : false);
    }
  }, [game, turnNumber, xo]);

  useEffect(() => {
    socket.on('playerTurn', (json) => {
      setTurnData(json);
    });

    socket.on('restart', () => {
      restart();
    });

    socket.on('opponent_joined', () => {
      setHasOpponent(true);
      setShare(false);
    });
  }, []);

  useEffect(() => {
    if (turnData) {
      const data = JSON.parse(turnData);
      let g = [...game];
      if (!g[data.index] && !winner) {
        g[data.index] = data.value;
        setGame(g);
        setTurnNumber(turnNumber + 1);
        setTurnData(false);
        setMyTurn(!myTurn);
        setPlayer(data.value);
      }
    }
  }, [turnData, game, turnNumber, winner, myTurn]);

  useEffect(() => {
    if (paramsRoom) {
      // means you are player 2
      setXO('O');
      socket.emit('join', paramsRoom);
      setRoom(paramsRoom);
      setMyTurn(false);
    } else {
      // means you are player 1
      const newRoomName = random();
      socket.emit('create', newRoomName);
      setRoom(newRoomName);
      setMyTurn(true);
    }
  }, [paramsRoom]);

  return (
    <div className="container">
      Room: {room}
      <button className="btn" onClick={() => setShare(!share)}>
        Share
      </button>
      {share ? (
        <>
          <br />
          <br />
          Share link: <input type="text" value={`${window.location.href}?room=${room}`} readOnly />
        </>
      ) : null}
      <br />
      <br />
      Turn: {myTurn ? 'You' : 'Opponent'}
      <br />
      {hasOpponent ? '' : 'Waiting for opponent...'}
      <p>
        {winner || turnNumber === 9 ? (
          <button className="btn" onClick={sendRestart}>
            Restart
          </button>
        ) : null}
        {winner ? <span>We have a winner: {player}</span> : turnNumber === 9 ? <span>It's a tie!</span> : <br />}
      </p>
      <div className="row">
        <Box index={0} turn={turn} value={game[0]} />
        <Box index={1} turn={turn} value={game[1]} />
        <Box index={2} turn={turn} value={game[2]} />
      </div>
      <div className="row">
        <Box index={3} turn={turn} value={game[3]} />
        <Box index={4} turn={turn} value={game[4]} />
        <Box index={5} turn={turn} value={game[5]} />
      </div>
      <div className="row">
        <Box index={6} turn={turn} value={game[6]} />
        <Box index={7} turn={turn} value={game[7]} />
        <Box index={8} turn={turn} value={game[8]} />
      </div>
    </div>
  );
}

const Box = ({ index, turn, value }) => {
  return (
    <div className="box" onClick={() => turn(index)}>
      {value}
    </div>
  );
};

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

const random = () => {
  return Array.from(Array(8), () => Math.floor(Math.random() * 36).toString(36)).join('');
};

export default App;

And for the final server code:

const express = require('express')
const dotenv = require('dotenv')
dotenv.config()

const PORT = 4000;
const INDEX = '/index.html';

const app = express()
app.use((_req, res) => res.sendFile(INDEX, { root: __dirname }))

const server = app.listen(PORT, () => console.log(`Listening on http://localhost:${PORT}...`));

// socket server
const socket = require('socket.io');
const io = socket(server, {
    cors: {
        origin: 'http://localhost:3000'
    }
});

io.on('connection', (socket) => {
    socket.on('reqTurn', (data) => {
        const room = JSON.parse(data).room
        io.to(room).emit('playerTurn', data)
    })

    socket.on('create', room => {
        socket.join(room)
    })

    socket.on('join', room => {
        socket.join(room)
        io.to(room).emit('opponent_joined')
    })

    socket.on('reqRestart', (data) => {
        const room = JSON.parse(data).room
        io.to(room).emit('restart')
    })
});

Add the start script in the package.json:

"scripts": {
    "test": "echo \"Error: no test specified\" && exit 1",
    "start": "node index.js",
},

Then start the server.

npm start

Also restart the React app to be able to reconnect to the server properly.

Now, you may get a share link and play with a friend or you can test it yourself by opening the link in another browser.

I hope you had fun and learned something in creating this mini project.

Happy coding!

Resources

You can play the game live here:

Offline: tictactoe-delta.vercel.app
Online: tictaconline.netlify.app

Client code here:

github.com/vjtvalero/tictactoe
main branch is for the offline version
online branch is for the online version

Server code here:

github.com/vjtvalero/tictactoe-server

Credits to DS. for the one-liner random string function: stackoverflow.com/a/44622300/2955091