Multiplayer UI - Create login screen and friends list (Part 1)

ui tutorials

7/31/2024

Kaloyan Geshev

This tutorial is the first part of the posts about creating a UI for a multiplayer game.

You can find the rest Multiplayer UI series here.

Making a multiplayer game requires users to log into their account before playing. User accounts are essential for connecting players in the game and lobby. They also enable features like adding friends, chatting, viewing other players’ stats, and inviting users to game sessions.

Prerequisites

In this tutorial, we will use React 18 with React Router 6 for the frontend and Express for the Backend part. Additionally, we’ll utilize our components: scrollable container and Grid .

Showcase Overview

In this example, we will create a multiplayer game UI featuring login and registration screens, and a friends list that shows their names, with options to add or remove friends.

The game server will handle API calls from the UI.

Note: When testing the sample, avoid entering sensitive information during registration. Passwords are stored as plain text in the database for testing purposes.

Source location

You can find the complete sample source within the ${Gameface package}/Samples/uiresources/UITutorials/MultiplayerUI directory.

  • src folder contains the UI source code.
  • api folder contains the Express server source code.

Refer to the README.md file in this directory for information on running the sample locally or previewing it without building or starting any processes.

Getting started - Backend

We will begin by setting up the backend using Express. To start, install the required modules:

Terminal window
1
npm i express mongoose express-session cors connect-mongo

Server index

The index.js file for our server will establish a connection to the database and start the server on port 3000. We have already set up a cloud MongoDB database and hardcoded the URL into the server index file.

/api/index.js
1
const express = require('express');
2
const mongoose = require('mongoose');
3
const router = require('./config/router');
4
const session = require('express-session');
5
const cors = require('cors');
6
const app = express();
7
const port = 3000;
8
const MongoStore = require('connect-mongo');
9
const { createServer } = require('node:http');
10
const path = require('node:path');
11
const server = createServer(app);
12
13
app.use(express.static(path.join(__dirname, '../dist'))); // Serve the frontend build folder if we choose to load the UI from the server. However, we suggest not loading your UI from the server and instead directly loading the index.html file in Gameface
14
15
app.use(cors())
16
app.use(express.json());
17
const mongoUrl = 'mongodb+srv://kaloyang:788DaVnkzXAfh8Q6@clsamples.pgjogro.mongodb.net/CLSampleDB?retryWrites=true&w=majority&appName=CLSamples';
18
mongoose.connect(mongoUrl);
19
20
const db = mongoose.connection;
21
db.on('error', console.error.bind(console, 'connection error:'));
22
db.once('open', () => {
23
console.log('Connected to MongoDB');
24
});
25
26
app.set('trust proxy', 1)
27
app.use(session({
28
secret: 'some secret',
29
resave: false,
30
name: 'api-session-key',
31
saveUninitialized: true,
32
store: MongoStore.create({ mongoUrl }),
33
cookie: { maxAge: 1000 * 60 * 60 }, // 1 hour
34
}))
35
36
app.use('/api', router);
37
38
// Serve the index.html for any GET requests if we load the UI from the server, which is an option that works in Gameface but not suggested.
39
app.get('*', (req, res) => {
40
res.sendFile(path.join(__dirname, '../dist', 'index.html'));
41
});
42
43
// Start the server
44
server.listen(port, () => {
45
console.log(`Server is running at http://localhost:${port}`);
46
});

Here, the frontend is expected to be built inside the ../dist folder so the server can serve it.

API routes

With the entry file for the server set up, we need to configure API routes. In this sample, we have user-related routes with the following entry points:

/api/config/router.js
1
const express = require('express');
2
const router = express.Router();
3
4
require('./routes/user')(router);
5
6
module.exports = router;
/api/config/routes/user.js
1
const UserController = require('../../controllers/userController');
2
const auth = require('../../middlewares/auth');
3
const idValidator = require('../../middlewares/idValidator');
4
5
module.exports = (router) => {
6
router.post('/login', UserController.login); // Handle user login
7
router.post('/logout', UserController.logout); // Handle user logout
8
router.post('/register', UserController.register); // Handle new user registration
9
router.get('/users/:id', auth, idValidator, UserController.getUser); // Get the user data
10
router.put('/users/:id', auth, idValidator, UserController.updateUser); // Update the user data
11
router.get('/users/:id/potential-friends', auth, UserController.getPotentialFriends); // Get potential friends for a specific user
12
router.get('/users/:id/friends', auth, idValidator, UserController.getUserFriends); // Get the friends list of a user
13
router.post('/users/:id/add-friend', auth, idValidator, UserController.addFriend); // Add another user as a friend
14
router.post('/users/:id/remove-friend', auth, idValidator, UserController.removeFriend); // Remove user from friends list
15
};

Middlewares

Certain routes are protected using middlewares such as auth, which verifies if a user is logged in, and idValidator, which checks if a valid user ID is present in the request parameters.

/api/middlewares/auth.js
1
const sessions = require("../db/sessions");
2
3
module.exports = async (req, res, next) => {
4
const sessionId = req.headers['session-id'];
5
if (!sessionId) return res.status(404).send('Unauthorized');
6
7
const session = await sessions.findOne({ _id: sessionId });
8
if (session && session.expires >= new Date()) return next();
9
10
res.status(404).send('Unauthorized');
11
}

To authenticate a user, we check for an existing session and confirm that it has not expired. The session expiration date is stored in the database. Since Gameface does not store session cookies on the frontend, requests should include the session-id header containing the session ID generated by the backend and stored in Gameface’s localStorage.

/api/middlewares/idValidator.js
1
const { ObjectId } = require("mongodb");
2
3
module.exports = (req, res, next) => {
4
if (!ObjectId.isValid(req.params.id)) return res.status(404).send('Not valid id!');
5
next();
6
}

This middleware validates whether the request from the frontend includes a valid MongoDB ID in the id parameter.

User database schema

After connecting to the database, we need to define the schema for the users table.

/api/db/users.js
1
const mongoose = require('mongoose');
2
3
const schema = new mongoose.Schema({
4
firstName: String,
5
lastName: String,
6
email: String,
7
password: String,
8
status: Boolean,
9
friends: [{ type: String }]
10
});
11
12
module.exports = mongoose.model('user', schema);

Once the schema is defined, we can use the model to access user data in the database.

Sessions database schema

To store user sessions when they log in, we need an additional table in the database with the following structure.

/api/db/sessions.js
1
const mongoose = require('mongoose');
2
3
const schema = new mongoose.Schema({
4
expires: Date,
5
session: String,
6
_id: String,
7
});
8
9
module.exports = mongoose.model('sessions', schema);

User controller

The User Controller manages requests and returns relevant responses by performing operations with the database.

For example, fetching user data is accomplished with the following code:

/api/controllers/userController.js
1
...
2
const User = require('../db/users'); // Get the user model so we can make calls to the database
3
...
4
class UserController {
5
...
6
async getUser(req, res) {
7
const user = await User.findById(req.params.id); // Find the user by id and return its data
8
if (!user) {
9
return res.status(404).send('User not found');
10
}
11
res.json(user);
12
}
13
...
14
}

Attention needs to be given to how users can log in or log out of our game. For user login, the backend should receive the user’s email, password, and session data. As previously mentioned, we store the session-id in localStorage in Gameface since there are no cookies. The frontend must pass the session-id header when making a login request to check if the user is already logged in.

Once the session is checked, we need to validate the user’s email and password. If the database does not contain a user with the provided email and password, the login request is denied. If the credentials are correct, the user’s status is set to true, and the sessionId along with user data is returned so the frontend can recognize a successful login.

Logging out is straightforward: change the user’s status to false and delete the relevant session from the database.

/api/controllers/userController.js
1
class UserController {
2
...
3
async login(req, res) {
4
const sessionId = req.headers['session-id'];
5
if (sessionId && await sessions.findOne({ _id: sessionId })) return res.send(sessionId);
6
7
const user = await User.findOne({ email: req.body.email, password: req.body.password });
8
if (!user) return res.status(404).send('Wrong email or password!');
9
user.status = true; // Change the user status to true
10
await user.save(); // Save the changed status
11
res.json({ id: user._id.toString(), sessionId: req.sessionID, firstName: user.firstName, lastName: user.lastName });
12
}
13
...
14
async logout(req, res) {
15
const sessionId = req.headers['session-id'];
16
const userId = req.headers['user-id'];
17
if (!sessionId) return res.status(200).send(); // If there is no session there is no user for logging out
18
19
if (!await changeUserStatus(userId, sessionId, false)) return res.status(200).send(); // Change the status to false
20
await sessions.deleteOne({ _id: sessionId }); // Delete the session from the sessions table
21
res.status(200).send();
22
}
23
...
24
}
/api/utils/user.js
1
async function changeUserStatus(userId, sessionId, status) {
2
if (!userId) return false;
3
if (!sessionId && !await sessions.findOne({ _id: sessionId })) return false;
4
5
const user = await users.findById(userId);
6
if (!user) return false;
7
user.status = status;
8
await user.save();
9
10
return true;
11
}

For more information on other handlers, refer to /api/controllers/userController.js.

With this basic backend setup, we can proceed to the frontend.

Getting started - Frontend

For the frontend, we will use the React framework. To avoid going into extensive detail, you can open the entire project in ${Gameface package}/Samples/uiresources/UITutorials/MultiplayerUI and check the following:

  • README.md - Contains information on how to run the sample.
  • package.json - Includes scripts for building the frontend, starting the server, etc.
  • webpack.config.js - Contains the webpack configuration that bundles the frontend source.
  • src folder - Holds the frontend source code.

Now, let’s focus on some details about the frontend.

React router and routes

We want our sample to have three pages: Login, Register, and Home. For this, we can use React Router. The home screen will have sub-routes for the friends list and adding friends.

/src/App.jsx
1
const App = () => (
2
<HashRouter basename='/'>
3
<AuthProvider>
4
<Routes>
5
<Route path='/' element={<ProtectedRoute><Home /></ProtectedRoute>} >
6
<Route index element={<Friends />} />
7
<Route path="/add-friends" element={<AddFriends />} />
8
</Route>
9
<Route path='/register' element={<Register />} />
10
<Route path='/login' element={<Login />} />
11
</Routes>
12
</AuthProvider>
13
</HashRouter>
14
);

Here, the <ProtectedRoute> component acts as a wrapper to grant access to the home page if you are logged in. If not, you will be redirected to the login page.

/src/components/ProtectedRoute.jsx
1
export const ProtectedRoute = ({ children }) => {
2
const { user } = useAuth();
3
if (!user) return <Navigate to="/login" />
4
5
return children;
6
}

The protected route checks for a logged-in user via the useAuth hook, which utilizes the AuthContext to provide user data.

/src/hooks/useAuth.jsx
1
import React, { createContext, useContext, useMemo } from "react";
2
import { useNavigate } from "react-router-dom";
3
import { useLocalStorage } from "./useLocalStorage";
4
const AuthContext = createContext();
5
6
export const AuthProvider = ({ children }) => {
7
const [user, setUser] = useLocalStorage("user", null);
8
const navigate = useNavigate();
9
10
const login = async (data) => {
11
setUser(data);
12
};
13
14
const logout = () => {
15
setUser(null);
16
navigate("/login", { replace: true });
17
};
18
19
const value = useMemo(
20
() => ({
21
user,
22
login,
23
logout,
24
}),
25
[user]
26
);
27
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
28
};
29
30
export const useAuth = () => {
31
return useContext(AuthContext);
32
};

To handle user data in localStorage, we use an additional hook, useLocalStorage. This hook stores or retrieves an item from localStorage based on the provided key. It also uses a default value if the key is not found in localStorage.

/src/hooks/useLocalStorage.jsx
1
import { useState } from "react";
2
3
export const useLocalStorage = (key, defaultValue = null) => {
4
const [storedValue, setStoredValue] = useState(() => {
5
try {
6
const value = localStorage.getItem(key);
7
if (value) return JSON.parse(value);
8
9
localStorage.setItem(key, JSON.stringify(defaultValue));
10
return defaultValue;
11
} catch (err) {
12
return defaultValue;
13
}
14
});
15
16
const setValue = (newValue) => {
17
try {
18
localStorage.setItem(key, newValue);
19
} catch (err) {
20
console.log(err);
21
}
22
setStoredValue(newValue);
23
};
24
25
return [storedValue, setValue];
26
};

Making requests to the backend

As a frontend developer, you typically use the fetch method to make requests to the backend. However, fetch is not supported by Gameface, so you should use XMLHttpRequest instead. Since we’ll be making these requests frequently, it’s practical to create a reusable hook. Additionally, we need to send the session ID and user ID as headers with each request. To facilitate this, we can use other hooks like useLocalStorage within our custom useFetch hook to retrieve the user data.

/src/hooks/useFetch.jsx
1
import { useNavigate } from 'react-router-dom';
2
import { useLocalStorage } from './useLocalStorage';
3
4
const useFetch = () => {
5
const [user, setUser] = useLocalStorage('user');
6
const navigate = useNavigate();
7
8
const fetch = (method, url, body) => {
9
return new Promise((resolve, reject) => {
10
const xhr = new XMLHttpRequest();
11
xhr.open(method, url);
12
if (user) {
13
xhr.setRequestHeader('session-id', user.sessionId);
14
xhr.setRequestHeader('user-id', user.id);
15
}
16
xhr.setRequestHeader('Content-Type', 'application/json;charset=UTF-8');
17
xhr.send(JSON.stringify(body));
18
xhr.onload = function () {
19
if (xhr.status === 200) return resolve([xhr, null]);
20
21
if (xhr.responseText === 'Unauthorized') {
22
setUser(null);
23
navigate('/login');
24
}
25
console.error(`Error ${xhr.status}: ${xhr.responseText}`);
26
return resolve([xhr, `${xhr.responseText}`]);
27
};
28
xhr.onerror = function () {
29
resolve([xhr, `An error occured!`]);
30
};
31
})
32
}
33
34
return fetch;
35
}
36
37
export default useFetch;

In this hook, const [user, setUser] = useLocalStorage('user'); retrieves the user data.

If user data is stored in localStorage, we set the request headers after opening the xhr.

1
const xhr = new XMLHttpRequest();
2
xhr.open(method, url)
3
if (user) {
4
xhr.setRequestHeader('session-id', user.sessionId);
5
xhr.setRequestHeader('user-id', user.id);
6
}

We also handle Unauthorized responses, ensuring that if the user is not authorized by the server, they are redirected to the login page to log in and try again.

1
xhr.onload = function () {
2
if (xhr.status === 200) return resolve([xhr, null]);
3
4
if (xhr.responseText === 'Unauthorized') {
5
setUser(null); // Remove the user data from the localstorage
6
navigate('/login'); // Navigate to the login page
7
}
8
console.error(`Error ${xhr.status}: ${xhr.responseText}`);
9
return resolve([xhr, `${xhr.responseText}`]);
10
};

Login and register pages

These pages are straightforward as they include a few input fields for user data. Since Gameface does not support the form element, we need to handle data submission manually. Here is an example for the login page, which is similar to the register page.

/src/pages/Login/Login.jsx
1
const Login = () => {
2
const [user] = useLocalStorage('user');
3
const navigate = useNavigate();
4
const [error, setError] = useState('');
5
if (user) {
6
return <Navigate to='/' />; // Navigate to home page if user is already logged in.
7
}
8
9
// Keep references to the email and password inputs to access their values on submit.
10
const [emailRef, passowrdRef] = [useRef(null), useRef(null)];
11
const { login } = useAuth();
12
const fetch = useFetch();
13
const onSubmit = async () => {
14
setError(''); // Reset error message
15
const email = emailRef.current.value;
16
const password = passowrdRef.current.value;
17
const [xhr, error] = await fetch('POST', `${process.env.SERVER_URL}/api/login`, { email, password }); // Make login POST request
18
if (error) {
19
setError(error); // Display error if any
20
return console.error(error);
21
}
22
23
login(xhr.responseText); // Log in the user and save user data from the server to localStorage
24
}
25
26
return (
27
<div className="login-wrapper">
28
<div className="login-screen">
29
<div className='form-item email'>
30
<span className='label'>Email:</span>
31
<input ref={emailRef} type="text" />
32
</div>
33
<div className='form-item password'>
34
<span className='label'>Password:</span>
35
<input ref={passowrdRef} type="password" />
36
</div>
37
{error && <span className='error-message'>{error}</span>}
38
<button className="login" onClick={onSubmit}>Login</button>
39
</div>
40
</div>
41
)
42
}
43
44
export default Login;

The register page follows a similar pattern. Here is a snippet for handling the submit request:

/src/pages/Register/Register.jsx
1
const onSubmit = async () => {
2
setMessage({ text: '', type: '' });
3
const email = emailRef.current.value;
4
const firstName = firstNameRef.current.value;
5
const lastName = lastNameRef.current.value;
6
const password = passowrdRef.current.value;
7
const [xhr, error] = await fetch('POST', `${process.env.SERVER_URL}/api/register`, { email, password, firstName, lastName }); // Make post register request to the server
8
if (error) {
9
setMessage({ text: error, type: 'error' }); // Display error if any
10
return console.error(error);
11
}
12
setMessage({ text: 'Successfully registered! You will be redirected to the login page in a few seconds', type: 'success' });
13
// Redirect to the login page after 3 seconds
14
setTimeout(() => {
15
navigate("/login");
16
}, 3000);
17
}

Home page

The home page contains a container with friends and a logout button. To display the subroutes defined for the home page, we use the Outlet component from React Router.

/src/pages/Home/Home.jsx
1
const Home = () => {
2
const { logout } = useAuth();
3
const fetch = useFetch();
4
const onLogout = useCallback(async () => {
5
const [xhr, error] = await fetch('POST', `${process.env.SERVER_URL}/api/logout`);
6
if (error) return;
7
logout();
8
}, []);
9
10
return (
11
<div id="main-screen">
12
<div className='friends-container-wrapper'>
13
<div className='friends-navigation'>
14
<NavLink className='nav-btn' to="/">Friends</NavLink >
15
<NavLink className='nav-btn' to="/add-friends">Add friends</NavLink >
16
</div>
17
<div className="friends-container">
18
<Outlet />
19
</div>
20
</div>
21
<button className='logout-btn nav-btn' onClick={onLogout}>Log out</button>
22
</div>
23
)
24
}
25
26
export default Home;

Friends list page

For the friends list page, we’ll use gameface-scrollable-container to enable scrolling through the list and gameface-grid to structure the list items. Our database schema stores an array of friend IDs for each user. Since this array doesn’t contain full friend information, we’ll fetch detailed data for each friend when needed.

/src/pages/Friends/Friends.jsx
1
const Friends = () => {
2
const [user] = useLocalStorage('user');
3
const [friends, setFriends] = useState([]);
4
const fetch = useFetch();
5
6
useEffect(() => {
7
updateFriendsList();
8
}, []);
9
10
const removeFriend = useCallback(async (event) => {
11
const userId = event.target.dataset.userId;
12
13
const [xhr, error] = await fetch('POST', `${process.env.SERVER_URL}/api/users/${user?.id}/remove-friend`, { friendId: userId }); // Remove friend form the user's friends list
14
if (error) return console.error(error);
15
16
await updateFriendsList();
17
}, [])
18
19
const updateFriendsList = useCallback(async () => {
20
const [xhr, error] = await fetch('GET', `${process.env.SERVER_URL}/api/users/${user?.id}/friends`); // The the user's friends ids
21
if (error) return;
22
23
const friendsIds = JSON.parse(xhr.responseText);
24
const friendsArr = [];
25
26
for (const friendId of friendsIds) {
27
const [friendXhr, errorFriend] = await fetch('GET', `${process.env.SERVER_URL}/api/users/${friendId}`); // Get data for a user that is a friend to the current one
28
if (errorFriend) continue;
29
30
const friendData = JSON.parse(friendXhr.responseText);
31
friendsArr.push(friendData);
32
};
33
34
setFriends(friendsArr);
35
});
36
37
return (
38
39
<div className="all-friends-wrapper">
40
<div className="friends">
41
<gameface-scrollable-container automatic className="scrollable-container-component">
42
<component-slot data-name="scrollable-content">
43
<div className='friends-list'>
44
{friends.map((friend, index) => {
45
return <div key={friend._id} className='friend guic-row'>
46
<div className='guic-col-11 friend-info'>
47
<div className='name'>{friend.firstName} {friend.lastName}</div>
48
</div>
49
<div className='guic-col-1 remove-btn'>
50
<span className='remove-icon' data-user-id={friend._id} onClick={removeFriend}>-</span>
51
</div>
52
</div>
53
})}
54
</div>
55
</component-slot>
56
</gameface-scrollable-container>
57
</div>
58
</div>
59
)
60
}
61
62
export default Friends;

Adding friends page

This page works similarly to the friends list page. It fetches all users from the database who are not the current user and are not already friends with the current user. These users are displayed in a list, and they can be added as friends by clicking the + button.

/src/pages/AddFriends/AddFriends.jsx
1
const AddFriends = () => {
2
const [user] = useLocalStorage('user');
3
const [users, setUsers] = useState([]);
4
const fetch = useFetch();
5
6
useEffect(() => {
7
updateUsersList();
8
}, []);
9
10
const updateUsersList = useCallback(async () => {
11
const [xhr, error] = await fetch('GET', `${process.env.SERVER_URL}/api/users/${user?.id}/potential-friends`); // Gets the potential friends for the current user from the server
12
if (error) return;
13
14
setUsers(JSON.parse(xhr.responseText));
15
}, [])
16
17
const addFriend = useCallback(async (event) => {
18
const userId = event.target.dataset.userId;
19
20
const [xhr, error] = await fetch('POST', `${process.env.SERVER_URL}/api/users/${user?.id}/add-friend`, { friendId: userId });
21
if (error) return console.error(error);
22
23
await updateUsersList(); // Update the users list when we added a friend
24
}, []);
25
26
return (
27
<div className="all-users-wrapper">
28
<div className="all-users">
29
<gameface-scrollable-container automatic fixed-slider-height className="scrollable-container-component">
30
<component-slot data-name="scrollable-content">
31
{users.map((user, index) => {
32
return <div key={user.id} className='user guic-row'>
33
<div className='guic-col-10 user-info'>
34
<div className='name'>{user.firstName} {user.lastName} &#40;{user.email}&#41;</div>
35
</div>
36
<div className='guic-col-1 add-btn'>
37
<span className='add-icon' data-user-id={user.id} onClick={addFriend}>+</span>
38
</div>
39
</div>
40
})}
41
</component-slot>
42
</gameface-scrollable-container>
43
</div>
44
</div>
45
)
46
}
47
48
export default AddFriends;

On this page