In this tutorial we will be building multiplayer Tic-Tac-Toe, using:
React (Front-end)
Fauna (Database)
Firebase (Authentication)
Node.js (Server)
Socket.io
Fauna
The star of the show, FaunaDB is a high-speed serverless NoSQL database. It provides a very simple and easy to use API with various drivers in several programming languages.
Create a React App
To quickly scaffold our app we will use create-react-app
npx create-react-app tic-tac-toe
Install the needed dependencies
yarn add node-sass react-router-dom cors http express concurrently faunadb firebase nanoid socket.io socket.io-client
Edit your package.json file to like something like this:
"scripts": {
"start": "react-scripts start",
"server": "nodemon -r esm server/index.js",
"dev": "concurrently \"nodemon ./server/index.js\" \"react-scripts start\"",
"build": "react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject"
},
Sign up for a FaunaDB account, if you haven't already.
Once you're signed in, go to the FaunaDB dashboard and click on New Database
You can name your database whatever you would like, for the sake of this tutorial we will name ours "TicTacToe".
Click New collection, which you can find either on the current page or in the Collections tab.
We'll name this collection "Rooms". It will be used to store all of the game rooms created.
In the Rooms Collections click New Index called "room_by_id", with the following values
Click on the Security tab and create a new key, choose the role Server, and we'll name our key "ServerKey", click save
You should get the secret key on the next screen. Create a file in the root directory of the react app we created earlier, and save the key in there.
Now we'll create a key for the client side, REACT_APP_FAUNADB_CLIENT_KEY and add it to the .env file
Copy your Key's Secret and paste it as a variable called REACT_APP_FAUNADB_CLIENT_KEY into a file called .env in the root directory of your project.
To access Environment variables in Create React App you need to prefix the variable name with **REACT_APP**
In the Security tab go to the Roles section and add a new custom role called Client
Go to the Firebase console
and click add project, give your project a name, we won't need Google Analytics for this project so we will disable it.
Let's now add Firebase to our app, get started by selecting the web
Once that is finished go to the Authentication tab and click Get Started, give your app a nickname, you can use the same one as when you created the project.
Once you're down, register the app. We will set up Firebase Hosting later.
Copy the content inside of the script tags, and create a file in the src directory called firebase.js
It should look something like this
import firebase from 'firebase';
import '@firebase/auth';
const firebaseConfig = {
apiKey: "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX",
authDomain: "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX",
databaseURL: "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX",
projectId: "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX",
storageBucket: "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX",
messagingSenderId: "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX",
appId: "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"
};
firebase.initializeApp(firebaseConfig);
export default firebase;
Now back to the Firebase site, continue to the console, go to the Authentication tab.
We will just use the Gmail Sign-in provider for Authentication, so enable that and save, we're done with the Firebase site for now.
Let's define 5 queries
Get a specific room by the roomID
const getRoom = (roomID) => client.query(q.Get(q.Match(q.Index('room_by_id'), roomID)));
Check if a room exists
const checkIfRoomExists = (roomID) => {
getRoom(roomID)
.then((ret) => {
return client.query(q.Exists(q.Ref(q.Collection('Rooms'), ret.ref.value.id)));
});
};
Create a room
const createRoom = (userID, profilePictureURL) => {
const id = nanoid();
const cells = JSON.stringify(Array(9).fill(null));
return client.query(
q.Create(q.Collection('Rooms'), {
data: {
id,
cells,
players: [{ id: userID, profilePictureURL }],
},
})
);
};
Update the TicTacToe board
const updateBoard = (roomID, cells) => {
return getRoom(roomID)
.then((ret) => {
return client
.query(
q.Update(ret.ref, {
data: {
cells,
},
})
)
.then((ret) => ret.data.cells)
})
};
Add team
const updateTeam = (roomID, team, userID) => {
return getRoom(roomID)
.then((ret) => {
return client
.query(
q.Update(ret.ref, {
data: {
[team]: userID
},
})
)
.then((ret) => ret.data)
})
};
All of these will be defined in a file called faunaDB.js
import faunadb from 'faunadb';
import { nanoid } from 'nanoid';
const q = faunadb.query;
const secret = process.env.FAUNADB_SERVER_KEY ? process.env.FAUNADB_SERVER_KEY : process.env.REACT_APP_FAUNADB_CLIENT_KEY;
const client = new faunadb.Client({ secret });
const getRoom = (roomID) => client.query(q.Get(q.Match(q.Index('room_by_id'), roomID)));
const checkIfRoomExists = (roomID) => {
getRoom(roomID)
.then((ret) => {
return client.query(q.Exists(q.Ref(q.Collection('Rooms'), ret.ref.value.id)));
});
};
const createRoom = (userID, profilePictureURL) => {
const id = nanoid();
const cells = JSON.stringify(Array(9).fill(null));
return client.query(
q.Create(q.Collection('Rooms'), {
data: {
id,
cells,
players: [{ id: userID, profilePictureURL }],
},
})
);
};
const updateBoard = (roomID, cells) => {
return getRoom(roomID)
.then((ret) => {
return client
.query(
q.Update(ret.ref, {
data: {
cells,
},
})
)
.then((ret) => ret.data.cells)
})
};
const updateTeam = (roomID, team, userID) => {
return getRoom(roomID)
.then((ret) => {
return client
.query(
q.Update(ret.ref, {
data: {
[team]: userID
},
})
)
.then((ret) => ret.data)
})
};
export { getRoom, checkIfRoomExists, createRoom, updateBoard, updateTeam };
The express server
const express = require('express');
const http = require('http');
const cors = require('cors');
const socket = require('socket.io');
const { updateBoard, updateTeam } = require('../src/utils/faunaDB');
const app = express();
app.use(cors());
const PORT = process.env.PORT || 8000;
const server = http.createServer(app);
const io = socket(server, {
cors: {
origin: '<http://localhost:3000>',
methods: ['GET', 'POST'],
},
});
io.on('connection', (socket) => {
console.log('New client connected');
socket.leaveAll();
socket.on('JOIN', (roomID) => {
socket.leaveAll();
socket.join(roomID);
socket.roomID = roomID;
});
socket.on('CHOOSE_TEAM', ({ roomID, team, userID, players }) => {
updateTeam(roomID, team, userID)
.then((ret) => {
const newPlayers = [...players, {[team]: ret[team]}];
socket.emit('SET_TEAM', team);
io.in(roomID).emit('CHOOSE_TEAM', newPlayers);
})
.catch((error) => console.log(error));
});
socket.on('MAKE_MOVE', ({ roomID, cells, id, player }) => {
const _cells = cells;
_cells[id] = player;
_cells.concat(_cells);
updateBoard(roomID, JSON.stringify(_cells))
.then((newCells) => {
if (player === 'X') player = 'O';
else player = 'X';
io.in(roomID).emit('MAKE_MOVE', { newCells: JSON.parse(newCells), newPlayer: player });
})
.catch((error) => console.log(error));
});
socket.on('REQUEST_RESTART_GAME', ({ roomID, player }) => {
socket.to(roomID).emit('REQUEST_RESTART_GAME', player);
});
socket.on('RESTART_GAME', (roomID) => {
const newCells = Array(9).fill(null);
updateBoard(roomID, JSON.stringify(newCells))
.then(() => io.in(roomID).emit('RESTART_GAME', { newCells }))
.catch((error) => console.log(error));
});
socket.on('disconnect', () => {
console.log('Client disconnected');
});
});
server.listen(PORT, () => console.log(`Listening on port ${PORT}`));
Back to the React App.js
import { BrowserRouter as Router } from 'react-router-dom';
import { AuthProvider } from './context/AuthContext';
import { PublicRoute, PrivateRoute } from './components/Routes';
import Navbar from './components/Navbar';
import GameRoom from './pages/GameRoom';
import PublicHome from './pages/PublicHome';
import PrivateHome from './pages/PrivateHome';
import JoinGame from './pages/JoinGame';
import CreateGame from './pages/CreateGame';
import './App.scss';
const App = () => {
return (
<AuthProvider>
<Router>
<Navbar />
<div className='app-component'>
<PublicRoute exact path='/' component={PublicHome} restricted={true} />
<PrivateRoute path='/home' component={PrivateHome} />
<PrivateRoute path='/create-game' component={CreateGame} />
<PrivateRoute path='/join-game' component={JoinGame} />
<PrivateRoute path='/room/:roomID' component={GameRoom} />
</div>
</Router>
</AuthProvider>
);
};
export default App;
Let's create 5 pages called CreateGame.js, JoinGame.js, and Navbar.js, PublicHome.js, and PrivateHome.js
Navbar.js
import React from 'react';
import { Link } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
import firebase from '../utils/firebase';
export const Navbar = () => {
const { isAuthenticated, handleSignIn } = useAuth();
const handleSignOut = () => firebase.auth().signOut();
return (
<nav className='navbar'>
<Link to='/'>Tic Tac Toe</Link>
<div>
{isAuthenticated ? (
<button onClick={handleSignOut}>Sign out</button>
) : (
<>
<button
onClick={handleSignIn}
style={{ marginRight: 10 }}
>
Sign Up
</button>
<button className='button-primary' onClick={handleSignIn}>
Sign In
</button>
</>
)}
</div>
</nav>
);
};
export default Navbar;
CreateGame.js
import { useState } from 'react';
import { useHistory } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
import { createRoom } from '../utils/faunaDB';
const CreateGame = () => {
const history = useHistory();
const [roomName, setRoomName] = useState('');
const { user } = useAuth();
const handleCreateGame = (e) => {
e.preventDefault();
if (roomName.trim() === '') return;
createRoom(user.uid, user.photoURL)
.then((response) => {
const id = response.data.id;
history.push(`/room/${id}`);
});
};
const handleOnChangeRoomName = (e) => setRoomName(e.target.value);
return (
<div className='join-game-page'>
<div className='form-container'>
<form>
<div>
<label htmlFor='roomName'>Room Name</label>
<input type='text' name='roomName' id='roomName' value={roomName} onChange={handleOnChangeRoomName} />
</div>
<button className='button-primary' onClick={handleCreateGame}>
Create Game
</button>
</form>
</div>
</div>
);
};
export default CreateGame;
JoinGame.js
import { useHistory } from 'react-router-dom';
import { useState } from 'react';
import { checkIfRoomExists } from '../utils/faunaDB';
const JoinGame = () => {
const history = useHistory();
const [roomID, setRoomID] = useState('');
const handleOnChangeRoomID = (e) => setRoomID(e.target.value);
const handleJoinGame = (e) => {
if (roomID.trim() === '') return;
e.preventDefault();
checkIfRoomExists(roomID)
.then((ret) => {
if (ret) history.push(`/room/${roomID}`);
else alert('Room does not exist');
});
};
return (
<div className='join-game-page'>
<div className='form-container'>
<form>
<label htmlFor='roomID'>Room ID</label>
<input type='text' name='roomID' id='roomID' value={roomID} onChange={handleOnChangeRoomID} />
<button className='button-primary' style={{ marginTop: 10 }} onClick={handleJoinGame}>Join Game</button>
</form>
</div>
</div>
)
}
export default JoinGame;
PublicHome.js
const PublicHome = () => {
return (
<div>
<h1>Welcome to Fauna Tic-Tac-Toe! 👋</h1>
<button className='button-primary' style={{ marginTop: 10 }}>Learn the rules</button>
</div>
);
};
export default PublicHome;
PrivateHome.js
import { useHistory } from "react-router-dom";
const PrivateHome = () => {
const history = useHistory();
return (
<div className='home-private-page'>
<div className='container'>
<button className='button-primary' onClick={() => history.push('/join-game')}>Join game</button>
<button className='button-secondary' onClick={() => history.push('/create-game')}>Create game</button>
</div>
</div>
);
};
export default PrivateHome;
Now let's create a Wrapper to protect certain Routes, in the components, create a Routes.js and add the following code:
import React from 'react';
import { Route, Redirect } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
export const PrivateRoute = ({ component: Component, ...rest }) => {
const { isAuthenticated, user } = useAuth();
return (
<Route {...rest} render={props => isAuthenticated
? <Component isAuthenticated={isAuthenticated} user={user} {...props} />
: <Redirect to={{ pathname: '/' }} />
}
/>
)
};
export const PublicRoute = ({ component: Component, restricted, ...rest }) => {
const { isAuthenticated } = useAuth();
return (
<Route {...rest} render={props => (
isAuthenticated && restricted ? <Redirect to='/home' /> : <Component {...props} />
)} />
);
};
The AuthContext to check whether the user is authenticated or not
import { useEffect, useState, createContext, useContext } from 'react';
import firebase from '../utils/firebase';
import Loading from '../components/Loading';
export const AuthContext = createContext();
export const AuthProvider = ({ children }) => {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const isAuthenticated = !!user;
useEffect(() => {
firebase.auth().onAuthStateChanged((user) => {
setUser(user);
setLoading(false);
});
}, []);
const handleSignIn = () => {
const provider = new firebase.auth.GoogleAuthProvider();
firebase
.auth()
.signInWithPopup(provider)
.then((res) => setUser(res.user))
.catch((error) => console.log(error.message));
};
if (loading) return <Loading />;
return (
<AuthContext.Provider value={{ user, isAuthenticated, handleSignIn }}>
{children}
</AuthContext.Provider>
);
};
export const useAuth = () => useContext(AuthContext);
export default AuthContext;
Board.js
const Square = ({ cells, cell, onClick, isActive }) => {
const checkIfIsActive = () => {
if (!isActive) return;
if (cells[cell] !== null) return false;
return true;
};
return (
<td className={checkIfIsActive() ? 'active' : ''} onClick={onClick}>
{cells[cell]}
</td>
);
};
export const Board = ({ cells, onClick, isActive }) => {
const renderSquare = (cell) => {
return <Square cell={cell} cells={cells} isActive={isActive} onClick={() => onClick(cell)} />;
};
return (
<table id='board'>
<tbody>
<tr>
{renderSquare(0)}
{renderSquare(1)}
{renderSquare(2)}
</tr>
<tr>
{renderSquare(3)}
{renderSquare(4)}
{renderSquare(5)}
</tr>
<tr>
{renderSquare(6)}
{renderSquare(7)}
{renderSquare(8)}
</tr>
</tbody>
</table>
);
};
export default Board;
GameRoom.js
import Board from '../components/Board';
import { Component } from 'react';
import io from 'socket.io-client';
import { getRoom } from '../utils/faunaDB';
import Loading from '../components/Loading';
export class GameRoom extends Component {
state = {
loading: false,
cells: Array(9).fill(null),
players: [],
player: 'X',
team: null,
};
componentDidMount() {
const {
history,
match: {
params: { roomID },
},
} = this.props;
getRoom(roomID)
.then(() => this.onReady())
.catch((error) => {
if (error.name === 'NotFound') {
history.push('/');
}
});
}
componentWillUnmount() {
if (this.state.socket) {
this.state.socket.removeAllListeners();
}
}
onSocketMethods = (socket) => {
const {
match: {
params: { roomID },
},
} = this.props;
socket.on('connect', () => {
socket.emit('JOIN', roomID);
});
socket.on('MAKE_MOVE', ({ newCells, newPlayer }) => {
this.setState({ cells: newCells });
this.setState({ player: newPlayer });
});
socket.on('CHOOSE_TEAM', (newPlayers) => {
this.setState({ players: newPlayers });
});
socket.on('SET_TEAM', (team) => {
this.setState({ team });
});
socket.on('REQUEST_RESTART_GAME', (player) => {
if (window.confirm(`${player} would like to restart the game`)) {
socket.emit('RESTART_GAME', roomID);
};
});
socket.on('RESTART_GAME', () => {
this.setState({ players: [] });
});
};
onReady = () => {
const socket = io('localhost:8000', { transports: ['websocket'] });
this.setState({ socket });
this.onSocketMethods(socket);
this.setState({ loading: false });
};
calculateWinner = (cells) => {
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 i = 0; i < lines.length; i++) {
const [a, b, c] = lines[i];
if (cells[a] && cells[a] === cells[b] && cells[a] === cells[c]) {
return cells[a];
}
};
return null;
};
handleClick = (id) => {
const {
team,
player,
players,
cells,
socket,
} = this.state;
const {
match: {
params: { roomID },
},
} = this.props;
if (players.length !== 2) return;
if (player !== team) return;
if (this.calculateWinner(cells) || cells[id]) {
return;
}
socket.emit('MAKE_MOVE', { roomID, cells, id, player });
};
chooseTeam = (newTeam) => {
const { team, players, socket } = this.state;
const {
match: {
params: { roomID },
},
} = this.props;
if (team !== null) return;
socket.emit('CHOOSE_TEAM', {
roomID,
team: newTeam,
userID: this.props.userID,
players,
});
};
restartGame = () => {
const { socket, team } = this.state;
const {
match: {
params: { roomID },
},
} = this.props;
socket.emit('REQUEST_RESTART_GAME', { roomID, player: team });
};
render() {
const {
loading,
cells,
player,
team,
players,
} = this.state;
if (loading) return <Loading />;
const winner = this.calculateWinner(cells);
let status;
if (winner) status = 'Winner: ' + winner;
else status = team === player ? `Turn: ${player} (You)` : `Turn: ${player}`;
return (
<div className='game-room'>
<div>
<h3 className='status'>{players.length === 2 && status}</h3>
<Board
cells={cells}
isActive={!winner && team === player}
onClick={(id) => this.handleClick(id)}
/>
<div className='buttons-container'>
{winner ? (
<button onClick={this.restartGame} className='restart-game-button'>Restart Game</button>
) : players.length === 2 ? null : (
<>
<button onClick={() => this.chooseTeam('X')}>
Join Team X
</button>
<button onClick={() => this.chooseTeam('O')}>
Join Team O
</button>
</>
)}
</div>
</div>
</div>
);
}
}
export default GameRoom;
The Styles (App.scss)
* {
margin: 0;
padding: 0;
text-decoration: none;
list-style-type: none;
}
html,
body,
#root {
height: 100%;
}
body {
font-family: 'Space Grotesk', sans-serif;
background-color: #eeeeee;
}
input[type='text'],
input[type='password'],
input[type='email'] {
height: auto;
padding: .5rem 1rem;
font-size: .95rem;
line-height: 1.5;
color: #495057;
background-color: #fff;
border: 1px solid #becad6;
font-weight: 300;
border-radius: .375rem;
box-shadow: none;
transition: box-shadow 250ms cubic-bezier(.27, .01, .38, 1.06), border 250ms cubic-bezier(.27, .01, .38, 1.06);
}
button {
font-weight: 300;
font-family: 'Space Grotesk', monospace, sans-serif;
border: 1px solid transparent;
padding: .75rem 1.25rem;
font-size: .875rem;
line-height: 1.125;
border-radius: 10px;
transition: all 250ms cubic-bezier(.27, .01, .38, 1.06);
cursor: pointer;
font-weight: 500;
}
a {
color: #ffffff;
}
:root {
--primary-color: #28df99;
}
%flex-complete-center {
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
}
.app-component {
@extend %flex-complete-center;
height: calc(100% - 80px);
width: 100%;
}
.navbar {
height: 80px;
background-color: #212121;
color: #ffffff;
width: 100%;
display: flex;
justify-content: space-between;
align-items: center;
padding: 0 10px;
box-sizing: border-box;
}
.loading-component {
@extend %flex-complete-center;
width: 100%;
height: 100%;
}
.loading-div {
border: 3px solid #10442f;
border-top-color: var(--primary-color);
border-radius: 50%;
width: 3em;
height: 3em;
animation: spin 1s linear infinite;
@keyframes spin {
to {
transform: rotate(360deg);
}
}
}
.form-container {
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
max-width: 95%;
box-sizing: border-box;
form {
width: 450px;
max-width: 100%;
display: flex;
flex-direction: column;
> div {
display: flex;
flex-direction: column;
margin-bottom: 20px;
}
.switch {
position: relative;
display: inline-block;
width: 54px;
height: 28px;
}
.switch input {
opacity: 0;
width: 0;
height: 0;
}
.slider {
position: absolute;
cursor: pointer;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: #ccc;
-webkit-transition: .4s;
transition: .4s;
}
.slider:before {
position: absolute;
content: "";
height: 20px;
width: 20px;
left: 4px;
bottom: 4px;
background-color: white;
-webkit-transition: .4s;
transition: .4s;
}
input:checked+.slider {
background-color: var(--primary-color);
}
input:focus+.slider {
box-shadow: 0 0 1px var(--primary-color);
}
input:checked+.slider:before {
-webkit-transform: translateX(26px);
-ms-transform: translateX(26px);
transform: translateX(26px);
}
/* Rounded sliders */
.slider.round {
border-radius: 34px;
}
.slider.round:before {
border-radius: 50%;
}
}
}
.button-primary {
color: #fff;
background-color: var(--primary-color);
border-color: var(--primary-color);
&:hover {
background-color: #2df3a7;
border-color: #2df3a7;
}
}
.button-secondary {
color: #212121;
background-color: #ffffff;
border-color: var(--primary-color);
color: #fff;
background-color: #0d7377;
border-color: #0d7377;
&:hover {
background-color: #118b8f;
border-color: #118b8f;
}
}
.home-private-page {
.container {
@extend %flex-complete-center;
border-radius: 10px;
width: 500px;
max-width: 95%;
height: 400px;
button {
width: 280px;
height: 50px;
max-width: 95%;
}
button:nth-of-type(2) {
margin: 15px 0;
}
}
}
.game-room {
.status {
text-align: center;
margin-bottom: 20px;
}
#board {
border-collapse: collapse;
font-family: monospace;
}
#winner {
margin-top: 25px;
width: 168px;
text-align: center;
}
td {
text-align: center;
font-weight: bold;
font-size: 25px;
color: #555;
width: 100px;
height: 100px;
line-height: 50px;
border: 3px solid #aaa;
background: #fff;
}
td.active {
cursor: pointer;
background: #eeffe9;
}
td.active:hover {
background: #eeffff;
}
.buttons-container {
display: flex;
justify-content: space-between;
margin-top: 15px;
button:nth-of-type(1) {
background-color: #28df99;
&.restart-game-button {
background-color: #facf5a;
margin: 0 auto;
}
}
button:nth-of-type(2) {
background-color: #086972;
color: #ffffff;
}
}
}
Top comments (0)