Multiplayer UI - Create leaderboard

ui tutorials

8/29/2024

Kaloyan Geshev

A games leaderboard ranks players based on performance, fostering competition by showing top scores and achievements. It encourages players to improve and climb the ranks, enhancing engagement in the game.

Showcase Overview

In this tutorial, we’ll build upon the previous multiplayer UI by adding a leaderboard feature. To accommodate this addition, we’ll refactor some of the existing pages, making it easier to integrate the new leaderboard.

Additionally, we’ll enhance the Express server by introducing new endpoints that retrieve user stats for display on the frontend.

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.

Ensure to run npm i before testing the sample.

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’ll start by updating the backend to include new endpoints that return relevant user stats for the frontend to display on the leaderboard page.

Add additional fields to the user

To store user stats, we’ll extend the user schema in our database by adding two new fields:

/api/db/user.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
totpSecret: String,
10
twoFactorEnabled: Boolean,
11
friends: [{ type: String }],
12
stats: {
13
games: Number,
14
wins: Number,
15
scores: Number
16
}
17
});
18
19
module.exports = mongoose.model('user', schema);

The stats object will store data such as games played, wins, and scores.

Creating endpoints to retrieve user stats

When the leaderboard page is accessed, all users’ data will be requested from the server. Additionally, when a user is selected from the table, their specific data will be retrieved.

To facilitate this, we’ll add two new routes to the user routes:

/api/config/routes/user.js
1
router.get('/users/rankings', auth, UserController.getUsersRankings);
2
router.get('/users/:id/rankings', auth, idValidator, UserController.getUserRankings);

getUsersRankings method

This method will return the stats data for all users.

/api/controllers/userController.js
1
class UserController {
2
async getUsersRankings(req, res) {
3
const stats = await User.find({}, { 'stats.scores': 1, firstName: 1, lastName: 1 }).sort({ 'stats.scores': -1 });
4
if (!stats) return res.status(500).send('Internal server error');
5
res.json(stats);
6
}
7
}

getUserRankings method

This method will return the stats for a specific user by passing their id in the route params.

/api/controllers/userController.js
1
class UserController {
2
async getUserRankings(req, res) {
3
const userStats = await User.findById(req.params.id, { stats: 1, firstName: 1, lastName: 1 });
4
if (!userStats) return res.status(500).send('Internal server error');
5
res.json(userStats);
6
}
7
}

Adding mock data to the newly registered users

Since our UI isn’t connected to a live game, we need to generate mock user stats when a new user registers. This can be done at the point of registration by adding mock data to our public database, which is used by the sample application.

/api/controllers/userController.js
1
class UserController {
2
async register(req, res) {
3
...
4
try {
5
const user = new User({ ...req.body, status: false });
6
const totalGames = parseInt(Math.random() * 500);
7
// Generate mock stats data for the new user
8
user.stats = {
9
games: totalGames,
10
wins: parseInt(Math.random() * totalGames),
11
scores: parseInt(Math.random() * 50000)
12
};
13
await user.save();
14
...
15
}
16
}

Getting started - Frontend

On the frontend, we’ll add a new leaderboard page to display user stats fetched from the server. Additionally, we’ll reorganize our friends page from the previous tutorial for better structure.

Refactor friends files structure

To improve organization, we’ll move the AddFriends and Friends pages into a new Friends folder.

1
pages
2
│ Friends
3
│ └───FriendsList
4
| | FriendsList.jsx
5
| | FriendsList.scss
6
│ └───AddFriends
7
| | AddFriends.jsx
8
| | AddFriends.scss
9
└───Friends
10
│ │ Friends.jsx
11
│ │ Friends.scss
12
└───AddFriends
13
│ AddFriends.jsx
14
│ AddFriends.scss

Add friends wrapper page

We’ll create a wrapper component in pages/Friends/FriendsPageWrapper to display both the friends list and add friends pages.

/src/pages/Friends/FriendsPageWraper/FriendsPageWrapper.jsx
1
import React from 'react';
2
import './FriendsPageWrapper.scss';
3
import { NavLink, Outlet } from 'react-router-dom';
4
5
const FriendsPageWrapper = () => {
6
return (
7
<div className="friends">
8
<div className='app-wrapper-sub-navigation'>
9
<NavLink className='sub-nav-btn' to="/friends" end>Friends list</NavLink >
10
<NavLink className='sub-nav-btn' to="/friends/add-friends">Add friends</NavLink >
11
</div>
12
<div className="app-wrapper-container">
13
<gameface-scrollable-container automatic className="scrollable-container-component">
14
<component-slot data-name="scrollable-content">
15
<Outlet />
16
</component-slot>
17
</gameface-scrollable-container>
18
</div>
19
</div>
20
)
21
}
22
23
export default FriendsPageWrapper;

Here, NavLink elements are set up for easy navigation between the friends list and add friends pages. The gameface-scrollable-container has been moved from both individual pages to this wrapper for simplicity.

Note: Use the end attribute on the main route (/friends) to ensure the correct NavLink is highlighted when navigating between subroutes.

The Outlet will render the component for the active subroute. Next, we’ll adjust the routing in App.jsx.

Updating routes and adding leaderboard page

In App.jsx, we’ll refactor our existing routes into subroutes and include a route for the leaderboard page.

/src/App.jsx
1
<Routes>
2
<Route path='/' element={<ProtectedRoute><Home /></ProtectedRoute>} >
3
<Route index element={<Friends />} />
4
<Route path="/add-friends" element={<AddFriends />} />
5
<Route element={<LeaderboardPageWrapper />} >
6
<Route index element={<Rankings />} />
7
</Route>
8
<Route path="friends" element={<FriendsPageWrapper />} >
9
<Route index element={<FriendsList />} />
10
<Route path="add-friends" element={<AddFriends />} />
11
</Route>
12
</Route>
13
<Route path='/register' element={<Register />} />
14
<Route path='/login' element={<Login />} />
15
<Route path='/totp' element={<Totp />} />
16
</Routes>

We’ve set the default home route to lead to the rankings subpage within the leaderboard page.

Leaderboard wrapper page

We’ll apply a similar approach to the leaderboard page wrapper as we did with the FriendsPageWrapper.

/src/pages/Leaderboard/LeaderboardPageWrapper/LeaderboardPageWrapper.jsx
1
import React, { useState } from 'react';
2
import './LeaderboardPageWrapper.scss';
3
import { NavLink, Outlet } from 'react-router-dom';
4
import PlayerStats from '../../../components/PlayerStats';
5
6
const LeaderboardPageWrapper = () => {
7
const [selectedPlayerId, setSelectedPlayerId] = useState('');
8
9
return (
10
<div className="leader-board">
11
<div className='app-wrapper-sub-navigation'>
12
<NavLink className='sub-nav-btn' to="/">Rankings</NavLink >
13
</div>
14
<div className="leader-board-container">
15
<div className="app-wrapper-container leader-board-container-outlet">
16
<Outlet context={[selectedPlayerId, setSelectedPlayerId]} />
17
</div>
18
<PlayerStats id={selectedPlayerId} />
19
</div>
20
</div>
21
)
22
}
23
24
export default LeaderboardPageWrapper;

This wrapper contains the rankings table, displayed in the Outlet via the Rankings component that we set up earlier in the router. It also includes the PlayerStats component, which appears on the right when a user is selected from the table. The PlayerStats component shows additional details about the selected player, such as their games, wins, losses, and win rate.

State Sharing

To ensure the PlayerStats component correctly displays data for the selected player, we define a state in the leaderboard page wrapper using const [selectedPlayerId, setSelectedPlayerId] = useState(''); and pass it as context to the Outlet, which is then used by the Rankings component. In Rankings, you can access this context as follows:

1
import { useOutletContext } from 'react-router-dom';
2
...
3
const [selectedPlayerId, setSelectedPlayerId] = useOutletContext();

Using setSelectedPlayerId updates the selectedPlayerId, which reflects in both the LeaderboardPageWrapper state and the PlayerStats component, enabling shared state between components in React. While you could use other state management approaches like redux, this method keeps things simple.

Rankings component

To display the rankings table, we’ll create a new Rankings page that renders within the LeaderboardPageWrapper’s Outlet.

This page includes a loader, a table with headers, the current user’s scores at the top, and a gameface-scrollable-container for the list of other users.

We begin by setting up component states, hooks, and the outlet context:

src/pages/Leaderboard/Rankings/Rankings.jsx
1
const [user] = useLocalStorage('user');
2
const [selectedPlayerId, setSelectedPlayerId] = useOutletContext();
3
const [loadingData, setLoadingData] = useState(true);
4
const [rankingsData, setRankingsData] = useState([]);
5
const fetch = useFetch();

Upon mounting, this component fetches user rankings to display:

src/pages/Leaderboard/Rankings/Rankings.jsx
1
useEffect(() => {
2
updateRankingsList();
3
}, []);
4
5
const updateRankingsList = useCallback(async () => {
6
const [xhr, error] = await fetch('GET', `${process.env.SERVER_URL}/api/users/rankings`);
7
if (error) return setLoadingData(false);
8
if (error) {
9
setLoadingData(false);
10
return console.error(error);
11
}
12
const rankings = JSON.parse(xhr.responseText);
13
setLoadingData(false);
14
setRankingsData(rankings);
15
});

As mentioned earlier, shared state allows the player stats panel to open when a user is selected from the table.

src/pages/Leaderboard/Rankings/Rankings.jsx
1
const openPlayerStats = useCallback((event) => {
2
const next = event.currentTarget.dataset.id;
3
setSelectedPlayerId((prev) => {
4
if (prev === next) return '';
5
return next;
6
});
7
}, []);

To simplify the code, we create memoized variables for the current user’s index and data:

src/pages/Leaderboard/Rankings/Rankings.jsx
1
const [currentUserIndex, currentUserData] = useMemo(() => {
2
const userIndex = rankingsData.findIndex((userData) => userData._id === user.id);
3
return [userIndex, rankingsData[userIndex]];
4
}, [rankingsData, user]);

Finally, we render the UI:

src/pages/Leaderboard/Rankings/Rankings.jsx
1
return (
2
<>
3
<Loader className="loader-container" visible={loadingData}></Loader>
4
{!loadingData && <UserRankItem
5
className="header"
6
rank="Rank"
7
userName="Name"
8
scores="Score" />
9
}
10
11
{!loadingData && currentUserData && <UserRankItem
12
className={`player player-current ${selectedPlayerId === currentUserData._id ? 'player-selected' : ''}`}
13
userName={`${currentUserData.firstName} ${currentUserData.lastName}`}
14
scores={currentUserData.stats.scores}
15
rank={currentUserIndex + 1}
16
userId={currentUserData._id}
17
onClick={openPlayerStats}
18
/>}
19
20
<gameface-scrollable-container automatic class="players-container">
21
<component-slot data-name="scrollable-content">
22
{!rankingsData.length && !loadingData && 'No data'}
23
{rankingsData.map((user, index) => {
24
if (user._id === currentUserData?._id) return null;
25
26
return <UserRankItem key={user._id}
27
className={`player ${selectedPlayerId === user._id ? 'player-selected' : ''}`}
28
userName={`${user.firstName} ${user.lastName}`}
29
scores={user.stats.scores}
30
rank={index + 1}
31
userId={user._id}
32
onClick={openPlayerStats}
33
/>
34
})}
35
</component-slot>
36
</gameface-scrollable-container>
37
</>
38
)

UserRankItem component

The UserRankItem component is used to create the table preview on the Rankings page. It accepts the following props:

  • rank: The user’s rank to be displayed in the table.
  • userName: The name of the user.
  • userId: The user’s unique ID.
  • scores: The user’s scores.
  • className: Additional classes for the component’s wrapper div.
  • onClick: Event handler triggered when the component is clicked, typically used to open the player’s stats.
src/components/UserRankItem.jsx
1
import React from "react";
2
3
const UserRankItem = ({ rank, userName, userId, scores, className, onClick }) => {
4
return <div className={`guic-row ${className}`} data-id={userId} onClick={onClick}>
5
<div className='guic-col-1 player-rank'>
6
{rank}
7
</div>
8
<div className='guic-col-8 player-name'>
9
{userName}
10
</div>
11
<div className='guic-col-2 player-score'>
12
{scores}
13
</div>
14
</div>
15
}
16
17
export default UserRankItem;

This component uses classes from the gameface-grid component to correctly align its contents.

You can use the UserRankItem component in different layouts within the table:

Table header

To create a table header, set the UserRankItem props like this:

1
<UserRankItem className="header" rank="Rank" userName="Name" scores="Score" />

User items

To display the current user at the top of the list, use UserRankItem as follows:

1
<UserRankItem
2
className={`player player-current ${selectedPlayerId === currentUserData._id ? 'player-selected' : ''}`}
3
userName={`${currentUserData.firstName} ${currentUserData.lastName}`}
4
scores={currentUserData.stats.scores}
5
rank={currentUserIndex + 1}
6
userId={currentUserData._id}
7
onClick={openPlayerStats}
8
/>

For other users, generate the items from the rankingsData array:

1
{rankingsData.map((user, index) => {
2
if (user._id === currentUserData?._id) return null;
3
4
return <UserRankItem key={user._id}
5
className={`player ${selectedPlayerId === user._id ? 'player-selected' : ''}`}
6
userName={`${user.firstName} ${user.lastName}`}
7
scores={user.stats.scores}
8
rank={index + 1}
9
userId={user._id}
10
onClick={openPlayerStats}
11
/>
12
})}

If the user in the data matches the current user, skip rendering their UserRankItem since it has already been displayed.

Display player stats when user is selected from the leaderboard

When a user is chosen from the leaderboard table, their stats will appear to the right of the table. To accomplish this, we’ll create a PlayerStats component. This component will show a loading indicator while fetching the user’s data from the server and, once ready, will display the number of games played, wins, losses, and win rate. Additionally, a donut chart will visualize the win/loss ratio.

We’ll start by defining a StatItem component, which will be responsible for displaying each individual stat and its corresponding value:

src/component/PlayerStats.jsx
1
const StatItem = ({ title, value }) => {
2
return <div className='player-stats-container-item'>
3
<div className='player-stats-container-item-title'>{title}</div>
4
<div className='player-stats-container-item-data'>{value}</div>
5
</div>
6
}

Then we can continue with the PlayerStats component. First we setup the states that the component will use and other things such as using the useDimensions hook that will be needed for making the chart responsive.

Next, we’ll build the PlayerStats component. This involves setting up the necessary state variables and using the useDimensions hook to make the donut chart responsive.

src/component/PlayerStats.jsx
1
const fetch = useFetch();
2
const [loading, setLoading] = useState(false);
3
const [userData, setUserData] = useState(null);
4
const loadingTimeoutRef = useRef(null);
5
const [{ width: chartWidth, height: chartHeight }, chartWrapperRef] = useDimensions(chartWrapperRef);
6
7
const setLoadingWithTimeout = useCallback((value) => {
8
loadingTimeoutRef.current = setTimeout(() => {
9
setLoading(value);
10
loadingTimeoutRef.current = null;
11
}, 300);
12
}, []);

We then fetch the player’s data from the server when the component mounts:

src/component/PlayerStats.jsx
1
useEffect(() => {
2
async function getUserData() {
3
setLoading(true);
4
if (!id) {
5
setLoadingWithTimeout(false)
6
return setUserData(null);
7
}
8
9
const [userStatsXhr, error] = await fetch('GET', `${process.env.SERVER_URL}/api/users/${id}/rankings`);
10
if (error) return setLoadingWithTimeout(false);
11
setUserData(JSON.parse(userStatsXhr.responseText));
12
setLoadingWithTimeout(false);
13
}
14
getUserData();
15
16
return () => {
17
if (loadingTimeoutRef.current) clearTimeout(loadingTimeoutRef.current);
18
}
19
}, [id]);

To simplify rendering, we define two memoized variables:

src/component/PlayerStats.jsx
1
const userStats = useMemo(() => {
2
if (!userData) return { games: 0, wins: 0, loses: 0, winRate: 0 };
3
4
return {
5
games: userData.stats.games,
6
wins: userData.stats.wins,
7
loses: userData.stats.games - userData.stats.wins,
8
winRate: (userData.stats.wins / userData.stats.games * 100).toFixed(1)
9
};
10
}, [userData]);
11
12
const chartData = useMemo(() => {
13
return [
14
{ name: 'Wins', value: userStats.wins },
15
{ name: 'Loses', value: userStats.loses }
16
]
17
}, [userStats])

Finally, we’re ready to render the PlayerStats component:

src/component/PlayerStats.jsx
1
return <div className={`leader-board-player ${id ? 'leader-board-player-visible' : ''}`}>
2
<Loader className="leader-board-player-loader" visible={loading}></Loader>
3
<div className={`player-stats ${!id || loading ? 'player-stats-invisible' : ''}`}>
4
<div ref={chartWrapperRef} className='player-stats-graph'>
5
<DonutChart width={chartWidth} height={chartHeight} data={chartData} />
6
</div>
7
{!userData && 'Unable for fetch user data.'}
8
{userData && <>
9
<div className='player-stats-name'>{userData.firstName} {userData.lastName}</div>
10
<div className='player-stats-container'>
11
<StatItem title="Games" value={userStats.games} />
12
<StatItem title="Wins" value={userStats.wins} />
13
<StatItem title="Loses" value={userStats.loses} />
14
<StatItem title="Win rate" value={userStats.winRate} />
15
</div>
16
</>
17
}
18
</div>
19
</div>

StatItem component

The StatItem component is stateless and serves to display data fetched from the server.

Loading indicator timeout

A loading indicator with a timeout is added to give the donut chart enough time to render and load the data.

useDimensions hook

To make the donut chart responsive, a useDimensions hook is used. This hook observes the dimensions of the chart’s parent element, updating the chart’s dimensions whenever the parent’s size changes.

/src/hooks/useDimensions.jsx
1
import React, { useEffect, useRef, useState } from 'react';
2
3
export const useDimensions = () => {
4
const ref = useRef(null);
5
const resizeObserverRef = useRef(null);
6
7
const getDimensions = () => {
8
return {
9
width: ref.current ? ref.current.offsetWidth : 0,
10
height: ref.current ? ref.current.offsetHeight : 0
11
};
12
};
13
14
const [dimensions, setDimensions] = useState(getDimensions);
15
16
const handleResize = () => {
17
const parentDimensions = getDimensions();
18
if (parentDimensions.width !== dimensions.width || parentDimensions.height !== dimensions.height) {
19
setDimensions(parentDimensions);
20
}
21
};
22
23
useEffect(() => {
24
resizeObserverRef.current = new ResizeObserver(handleResize);
25
if (ref.current) resizeObserverRef.current.observe(ref.current);
26
27
return () => {
28
if (resizeObserverRef.current) resizeObserverRef.current.disconnect();
29
};
30
}, [ref]);
31
32
return [dimensions, ref];
33
}

Instead of watching for document resize, we use ResizeObserver to monitor changes to the dimensions of the chart’s parent element.

DonutChart component

We’ll use the d3 library to create a donut chart in React. A post describing how to use this library in Gameface with plain JavaScript can be found here.

/src/components/DounutChart.jsx
1
import React, { useEffect, useMemo } from "react";
2
import * as d3 from "d3";
3
import './DounutChart.scss';
4
5
const colors = [
6
{ fill: '#009B2C80', stroke: '#15FF00' },
7
{ fill: '#81000080', stroke: '#FF000080' },
8
]
9
10
const STOKE_WIDTH = 3;
11
12
const getArcsConfig = (radius, arcData) => {
13
return {
14
innerRadius: parseInt(radius * 0.80),
15
outerRadius: radius,
16
startAngle: arcData.startAngle,
17
endAngle: arcData.endAngle,
18
}
19
}
20
21
export const DonutChart = ({ width, height, data }) => {
22
const radius = useMemo(() => {
23
return (Math.min(width, height) / 2) - STOKE_WIDTH - 20;
24
}, [width, height]);
25
26
const pie = useMemo(() => {
27
const pieGenerator = d3.pie().value((d) => d.value).sort(null);
28
return pieGenerator(data);
29
}, [data]);
30
31
const arcs = useMemo(() => {
32
const arcPathGenerator = d3.arc().padAngle(0.08);
33
return pie.map((p) => arcPathGenerator(getArcsConfig(radius, p)));
34
}, [radius, pie]);
35
36
useEffect(() => {
37
d3.selectAll('.pie-group-arc').transition().duration(500).delay(250)
38
.attrTween('d', (d, i) => {
39
const interpolate = d3.interpolate({ startAngle: 0, endAngle: 0 }, pie[i]);
40
41
return (t) => {
42
const arcPathGenerator = d3.arc().padAngle(0.08);
43
return arcPathGenerator(getArcsConfig(radius, interpolate(t)));
44
};
45
});
46
}, [pie]);
47
48
return (
49
<svg className="pie" viewBox={`0 0 ${width} ${height}`}>
50
<g className="pie-group" style={{ transform: `translate(${(width / 2)}px, ${(height / 2)}px)` }}>
51
{arcs.map((arc, i) => {
52
const { fill, stroke } = colors[i];
53
return <path className="pie-group-arc" key={i} d={arc} fill={fill} stroke={stroke} strokeWidth={STOKE_WIDTH} />;
54
})}
55
</g>
56
</svg >
57
);
58
};

Calculating the chart radius

The outer radius of the chart is recalculated dynamically based on the width and height of the parent element. This ensures the chart is responsive, adjusting each time the parent size changes.

Generating the pie

A new pie chart is generated whenever the chart data changes. The data passed from the PlayerStats component represents the user’s wins and losses, regenerating the pie whenever a different user is selected.

Generating the arcs

The arcs of the chart are generated based on the outer radius and the pie data. They will regenerate if either the radius or the user data changes.

Animating the arcs

To animate the arcs when created, the useEffect hook is used to handle the animation when the chart is mounted or the pie data changes.

Making the chart responsive

To make the chart responsive, the svg element needs the viewBox attribute set dynamically:

1
<svg className="pie" viewBox={`0 0 ${width} ${height}`}>

Additionally, the group holding the arcs should be moved to the center of the svg element:

1
<g className="pie-group" style={{ transform: `translate(${(width / 2)}px, ${(height / 2)}px)` }}>

On this page