Build a live leaderboard with Preact

Introduction

In this article, I’ll show you how to build a leaderboard for a simple game that updates in realtime after every round. You can find the entire source code of the application in this GitHub repository.

NOTE: Leaderboards are a feature in many online games which is used to improve competition among players since it gives them instant feedback on their rankings which tends to increases gamer satisfaction and keeps them playing for longer.

Prerequisites

To follow through with the tutorial, a basic knowledge of JavaScript (ES6) and React or Preact is required. You also need to have Node.js (v6.x or later) and npm installed on your machine. Installation instructions can be found here.

Getting started

To get started, install preact-cli globally with npm as we’ll be using it shortly to bootstrap a new Preact application.

    npm install -g preact-cli

Once the installation completes, the preact command will become available on your machine. Run the following command in the terminal to create a new Preact app.

    preact create simple leaderboard

This command creates a new folder called leaderboard in your working directory and installs all the necessary dependencies needed to build and run the application.

As soon has the command has finished running, cd into the leaderboard directory and run npm run start to start the development server. The application should be viewable at http://localhost:8080. If port 8080 is not available on your machine, preact will provide another port for you to use.

Creating our game

The game we will be making is the classic rock-paper-scissors game which is quite popular in many parts of the world. If you’re not familiar with the rules, you can check this Wikipedia entry to get up to speed with how the game works.

In our implementation of the game, the player will get three points for a win, one for a draw and no points for a defeat. The score, as well as the player’s position on the leaderboard, will be updated in realtime.

The first step is to create the frontend of the game with Preact. Open up index.js in the root of the leaderboard directory and change its contents to look like this:

1// index.js
2    
3    import './style';
4    import { Component } from 'preact';
5    
6    export default class App extends Component {
7      constructor(props) {
8        super(props);
9        this.state = {
10          computerPick: null,
11          result: null,
12          leaderboard: [],
13        }
14    
15        this.handleClick = this.handleClick.bind(this);
16      }
17    
18      handleClick(event) {
19        const { value } = event.target;
20    
21        fetch(`http://localhost:7777/play?userPick=${value}`)
22          .then(response => response.json())
23          .catch(error => console.log(error));
24      }
25    
26      render() {
27        const { leaderboard, computerPick, result } = this.state;
28        const sortedLeaderboard = leaderboard.sort((a, b) => b.score > a.score);
29        const tableBody = sortedLeaderboard.map((player, index) => (
30          <tr>
31            <td>{index + 1}</td>
32            <td>{player.name}</td>
33            <td>{player.score}</td>
34          </tr>
35        ));
36    
37        const computerPicked = computerPick ?
38          <span class="computer-message">The computer chose {computerPick}</span> : null;
39    
40        let message;
41        if (result !== null) {
42          message = result === 1 ?
43            <span class="message-content">It's a draw</span> :
44            result === 0 ? <span class="message-content fail">You Lost!</span> :
45            <span class="message-content success">You won!</span>;
46        } else {
47          message = null;
48        }
49    
50        return (
51          <div class="App">
52            <h1>Rock Paper Scissors</h1>
53    
54            <div class="button-row">
55              <button onClick={this.handleClick} value="rock" class="rock">Rock</button>
56              <button onClick={this.handleClick} value="paper" class="paper">Paper</button>
57              <button onClick={this.handleClick} value="scissors" class="scissors">Scissors</button>
58            </div>
59    
60            <div class="message">
61              {message}
62              {computerPicked}
63            </div>
64    
65            <div class="leaderboard">
66              <table>
67                <thead>
68                  <tr>
69                    <th>Rank</th>
70                    <th>Name</th>
71                    <th>Score</th>
72                  </tr>
73                </thead>
74                <tbody>
75                  {tableBody}
76                </tbody>
77              </table>
78            </div>
79          </div>
80        );
81      }
82    }

The three buttons allow us to select rock, paper or scissors for a round, while the table below it will contain the top scorers including the current player. We also have a message section where the winner of a round will be declared (if any).

Next, change up style.css to look like this:

1// style.css
2    
3    html,
4    body {
5      font: 14px/1.21 'Helvetica Neue', arial, sans-serif;
6      font-weight: 400;
7    }
8    
9    .App {
10      max-width: 500px;
11      margin: 50px auto;
12      text-align: center;
13    }
14    
15    .button-row,
16    .scoreboard {
17      display: flex;
18      justify-content: space-between;
19      margin-bottom: 20px;
20    }
21    
22    button {
23      transition: box-shadow 0.3s;
24      font-size: 24px;
25      padding: 20px 25px;
26      width: 150px;
27      margin: 0px 10px 0px 10px;
28      background-color: white;
29      border: 4px solid rebeccapurple;
30      border-radius: 3px;
31      box-shadow: 2px 2px 2px 0px rgba(168,168,168,1);
32      cursor: pointer;
33    }
34    
35    button:hover {
36      box-shadow: 4px 4px 6px 0px rgba(168,168,168,1);
37    }
38    
39    button:focus {
40      background-color: #222;
41      color: #fff;
42    }
43    
44    .message span {
45      display: block;
46      text-align: center;
47    }
48    
49    .message-content {
50      font-weight: bold;
51      font-size: 20px;
52      padding: 20px;
53      background-color: #c0c0c0;
54      margin-bottom: 20px;
55    }
56    
57    .success {
58      background-color: #0f0;
59    }
60    
61    .fail {
62      background-color: #f00;
63    }
64    
65    table {
66      width: 100%;
67    }
68    
69    th,
70    td {
71      padding: 12px 15px;
72      text-align: left;
73      border-bottom: 1px solid #E1E1E1; 
74    }
75    
76    th:first-child,
77    td:first-child {
78      padding-left: 0; 
79    }
80    
81    th:last-child,
82    td:last-child {
83      padding-right: 0;
84    }

At this moment, the application should look like this:

preact-leaderboard-demo-1

Setting up the server

Let’s set up a simple Express server to handle how the score is determined for a round while also broadcasting updates to the frontend.

Run the following command to install the dependencies we’ll be needing:

    npm install express cors dotenv pusher

Head over to the Pusher website and to grab the necessary credentials. Once you have signed up, select Channels apps on the sidebar, and hit Create Channels app to create a new app.

You can retrieve your credentials from the API Keys tab, then create a variables.env file in the root of your project directory and populate it with the following contents:

1// variables.env
2    
3    PORT=7777
4    PUSHER_APP_ID=<your app id>
5    PUSHER_APP_KEY=<your app key>
6    PUSHER_APP_SECRET=<your app secret>
7    PUSHER_APP_CLUSTER=<your app cluster>

Next, create a new server.js file in your project directory and change it to look like this:

1// server.js
2    
3    require('dotenv').config({ path: 'variables.env' });
4    
5    const express = require('express');
6    const bodyParser = require('body-parser');
7    const cors = require('cors');
8    const Pusher = require('pusher');
9    
10    const pusher = new Pusher({
11      appId: process.env.PUSHER_APP_ID,
12      key: process.env.PUSHER_APP_KEY,
13      secret: process.env.PUSHER_APP_SECRET,
14      cluster: process.env.PUSHER_APP_CLUSTER,
15      encrypted: true,
16    });
17    
18    const app = express();
19    
20    app.use(cors());
21    app.use(bodyParser.json());
22    app.use(bodyParser.urlencoded({ extended: true }));
23    
24    app.set('port', process.env.PORT || 7777);
25    const server = app.listen(app.get('port'), () => {
26      console.log(`Express running → PORT ${server.address().port}`);
27    });

We’re going to pretend that we have a leaderboard already with the scores of previous players. Normally, you’d fetch this data from the server, but in this scenario, we’ll hardcode the values in a JSON file.

Create a file called leaderboard.json in your project directory and update its contents to look like this:

1// leaderboard.json
2    
3    {
4      "players": [
5        {
6          "name": "Mike Koala",
7          "score": 95
8        },
9        {
10          "name": "Gina Kangaroo",
11          "score": 92
12        },
13        {
14          "name": "Sally Tortoise",
15          "score": 86
16        },
17        {
18          "name": "Kim Lobster",
19          "score": 67
20        },
21        {
22          "name": "Peter Rabbit",
23          "score": 56
24        },
25        {
26          "name": "Frank Leopard",
27          "score": 43
28        },
29        {
30          "name": "Mary Hyena",
31          "score": 34
32        },
33        {
34          "name": "Caroline Bear",
35          "score": 32
36        },
37        {
38          "name": "Tom Eagle",
39          "score": 24
40        },
41        {
42          "name": "Jim Unicorn",
43          "score": 11
44        },
45        {
46          "name": "Player 1",
47          "score": 0
48        }
49      ]
50    }

Display the leaderboard on page load

When our application loads, we need to update the table with the existing leaderboard values as shown in the leaderboard.json file.

Within index.js, add the following lifecycle hook that loads the leaderboard when the App component mounts successfully:

1// index.js
2    
3    componentDidMount() {
4      fetch('http://localhost:7777/leaderboard')
5        .then(response => response.json())
6        .then(data => {
7          this.setState({
8            leaderboard: [...data.players],
9          });
10        })
11        .catch(error => console.log(error));
12    }

Next, let’s add the /leaderboard route to the server. Hitting this route will simply send the leaderboard.json file to the client. First add the following under the other require statements at the top:

1// server.js
2    
3    const leaderboard = require('./leaderboard.json');

Then add the /leaderboard route as shown below:

1// server.js
2    
3    ...
4    app.use(bodyParser.urlencoded({ extended: true }));
5    
6    app.get('/leaderboard', (req, res) => {
7      res.json(leaderboard);
8    });
9    ...

Game logic

In the index.js file, we have a handleClick function that is invoked when each button is clicked. This function sends whatever value is clicked to the server through the /play endpoint. But this endpoint does not exist on the server yet so we’ll go ahead and create it in the next step.

Before we continue, we need to create a new function that will help us determine the winner of each round or whether it is a draw. Let’s call this function compare, and create a new compare.js file to house the function as shown below:

1// compare.js
2    
3    const compare = (choice1, choice2) => {
4      if (choice1 === choice2) {
5        return 1;
6      }
7      if (choice1 === "rock") {
8        if (choice2 === "scissors") {
9          return 3;
10        } else {
11          // paper wins
12          return 0;
13        }
14      }
15      if (choice1 === "paper") {
16        if (choice2 === "rock") {
17          return 3;
18        } else {
19          return 0;
20        }
21      }
22      if (choice1 === "scissors") {
23        if (choice2 === "rock") {
24          return 0;
25        } else {
26          return 1;
27        }
28      }
29    };
30    
31    module.exports = compare;

The compare function checks what the user plays (choice1) and compares it with what the computer picks (choice2) to determine a winner. As explained previously, the user gets three points for a win, one for a draw and zero points for a defeat.

Let’s go ahead and make use of the compare in the new /play route. We need to import it first below the other require statements:

1// server.js
2    
3    const compare = require('./compare');

Then create the /play route below /leaderboard as shown below:

1app.get('/play', (req, res) => {
2      const { userPick } = req.query;
3      const arr = ['rock', 'paper', 'scissors'];
4      const computerPick = arr[Math.floor(Math.random() * 3)];
5    
6      const points = compare(userPick, computerPick);
7    });

The computer picks a random value from the arr variable anytime this route is hit. This value is then compared with whatever the user selected and the resulting points value for the user is stored in the points variable.

When the user scores a point, we need to update the leaderboard in realtime so that the player can see his progress in the rankings while playing the game. We’ll be making use of Pusher Channels to achieve this functionality.

Let’s install the Pusher client library through npm. This is how we’ll use Channels in our Preact app.

    npm install pusher-js

Then import it at the top of index.js:

1// index.js
2    
3    import Pusher from 'pusher-js';

Next, we’ll open a connection to Channels within componentDidMount() and use the subscribe() method from Pusher to subscribe to a new channel called leaderboard. Finally, we’ll listen for the update on the bot channel using the bind method and update the application state once we receive a message.

Don’t forget to replace the <your app key> and <your app cluster> placeholder with the appropriate values from your Pusher dashboard.

1// index.js
2    
3    componentDidMount() {
4      const pusher = new Pusher('<your app key>', {
5        cluster: '<your app cluster>',
6        encrypted: true,
7      });
8    
9      const channel = pusher.subscribe('leaderboard');
10      channel.bind('update', data => {
11        const { leaderboard } = this.state;
12        const userIndex = leaderboard.findIndex(e => e.name === 'Player 1');
13        leaderboard[userIndex].score += data.points;
14    
15        this.setState({
16          computerPick: data.computerPick,
17          result: data.points,
18          leaderboard,
19        });
20      });
21    
22      fetch('http://localhost:7777/leaderboard')
23        .then(response => response.json())
24        .then(data => {
25          this.setState({
26            leaderboard: [...data.players],
27          });
28        })
29        .catch(error => console.log(error));
30    }

Finally, we’ll trigger updates from the server when the user’s points for a round have been determined.

Change the /play route within server.js to look like this:

1// server.js
2    
3    app.get('/play', (req, res) => {
4      const { userPick } = req.query;
5      const arr = ['rock', 'paper', 'scissors'];
6      const computerPick = arr[Math.floor(Math.random() * 3)];
7    
8      const points = compare(userPick, computerPick);
9    
10      pusher.trigger('leaderboard', 'update', {
11        points,
12        computerPick,
13      });
14    });

You can start the server by running node server.js in a different terminal window, and test out the game by playing a few rounds. You should see the leaderboard update as you score some points!

preact-leaderboard-demo-2

Conclusion

I’m sure you’ll agree that setting up Pusher Channels for realtime updates to the game leaderboard was easy enough. There’s so much more you can do with Channels so I recommend digging into the docs to find more about the service and other awesome features it has.

Thanks for reading! Remember that you can find the source code of this app in this GitHub repository.