Build a live game updates site with Express

Introduction

How to build a live game updates site with Express and Pusher Channels

Sports are fun social activities, but unfortunately, we aren’t always to participate or watch the actual action. In such cases, it’s useful to have a means of following the action as it happens. In today’s tutorial, we’ll be building a web app using Node.js (Express) that allows anyone to follow the progress of a game in realtime.

Our app will provide an interface that allows an admin to post updates on an ongoing game, which users will see in realtime. Here’s a preview of our app in action:

image_missing

Prerequisites

Setting up

We’ll create a new app using the Express application generator:

1npx express-generator --view=hbs live-game-updates-express
2    cd live-game-updates-express
3    npm install

NOTE: if the line with npx throws an error about the npx command not being recognized, you can install npx by running:

    npm install -g npx

Let’s add our dependencies:

    npm install dotenv express-session mongoose passport passport-local pusher

We’ll use dotenv to load our Pusher Channels app credentials from a .env file, mongoose to manage our models via MongoDB documents, passport (together with passport-local and express-session) for authentication, and Pusher Channels for the realtime APIs.

Configuring the application

We’re going to make some changes to our app.js. First, we’ll implement a very simple authentication system that checks for a username of ‘admin’ and a password of ‘secret’. We’ll also initialize our MongoDB connection. Modify your app.js so it looks like this:

1// app.js
2    require('dotenv').config();
3    
4    const express = require('express');
5    const path = require('path');
6    const logger = require('morgan');
7    const session = require('express-session');
8    const passport = require('passport');
9    const LocalStrategy = require('passport-local').Strategy;
10    
11    passport.use(new LocalStrategy((username, password, done) => {
12            if (username === 'admin' && password === 'secret') {
13                return done(null, {username});
14            }
15            return done(null, null)
16        })
17    );
18    passport.serializeUser((user, cb) => cb(null, user.username));
19    passport.deserializeUser((username, cb) => cb(null, { username }));
20    
21    const app = express();
22    require('mongoose').connect('mongodb://localhost/live-game-updates-express');
23    
24    // view engine setup
25    app.set('views', path.join(__dirname, 'views'));
26    app.set('view engine', 'hbs');
27    
28    app.use(logger('dev'));
29    app.use(express.json());
30    app.use(express.urlencoded({extended: false}));
31    app.use(express.static(path.join(__dirname, 'public')));
32    app.use(session({ secret: 'anything' }));
33    app.use(passport.initialize());
34    app.use(passport.session());
35    app.use((req, res, next) => {
36        res.locals.user = req.user;
37        next();
38    });
39    app.use('/', require('./routes/index'));
40    
41    module.exports = app;

That’s all we need to do. Now let’s go ahead and create our app’s views.

Building the views

First, we’ll create the home page. It shows a list of ongoing games. If the user is logged in as the admin, it will show a form to start recording a new game.

Before we do that, though, let’s modify our base layout which is used across views. Replace the contents of views/layout.hbs with the following:

1<!-- views/layout.hbs -->
2    <!DOCTYPE html>
3    <html>
4    <head>
5        <title>Live Game Updates</title><!-- Latest compiled and minified CSS -->
6        <link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.7/css/bootstrap.min.css"
7              integrity="sha384-BVYiiSIFeK1dGmJRAkycuHAHRg32OmUcww7on3RYdg4Va+PmSTsz/K68vbdEjh4u" crossorigin="anonymous">
8    </head>
9    <body>
10    <main class="py-4">
11        {{{body}}}
12    </main>
13    </body>
14    </html>

Now, replace the contents of index.hbs file with the following:

1<!-- views/home.hbs -->
2    
3    <div class="container">
4        <h2>Ongoing games</h2>
5        {{#if user }}
6            <form method="post" action="/games" class="form-inline">
7                <input class="form-control" name="first_team" placeholder="First team" required>
8                <input class="form-control" name="second_team" placeholder="Second team" required>
9                <input type="hidden" name="first_team_score" value="0">
10                <input type="hidden" name="second_team_score" value="0">
11                <button type="submit" class="btn btn-primary">Start new game</button>
12            </form>
13        {{/if}}
14        <br>
15        {{#each games }}
16            <a class="card bg-dark" href="/games/{{ this.id }}">
17                <div class="card-body">
18                    <div class="card-title">
19                        <h4>{{ this.first_team }} {{ this.first_team_score }} - {{ this.second_team_score }} {{ this.second_team }}</h4>
20                    </div>
21                </div>
22            </a>
23        {{/each}}
24    </div>

The next view is that of a single game. Put the following code in the file views/game.hbs:

1<!-- views/game.hbs -->
2    
3    <div id="main" class="container" xmlns:v-on="http://www.w3.org/1999/xhtml">
4        <h2>\{{ game.first_team }}
5            <span {{#if user}} contenteditable {{/if}} v-on:blur="updateFirstTeamScore">\{{ game.first_team_score }}</span>
6            -
7            <span {{#if user}} contenteditable {{/if}} v-on:blur="updateSecondTeamScore">\{{ game.second_team_score }}</span>
8            \{{ game.second_team }}</h2>
9        {{#if user }}
10        <div class="card">
11            <div class="card-body">
12                <form v-on:submit="updateGame">
13                    <h6>Post a new game update</h6>
14                    <input class="form-control" type="number" v-model="pendingUpdate.minute"
15                           placeholder="In what minute did this happen?">
16    
17                    <input class="form-control" placeholder="Event type (goal, foul, injury, booking...)"
18                           v-model="pendingUpdate.event_type">
19    
20                    <input class="form-control" placeholder="Add a description or comment..."
21                           v-model="pendingUpdate.description">
22    
23                    <button type="submit" class="btn btn-primary">Post update</button>
24                </form>
25            </div>
26        </div>
27        {{/if}}
28        <br>
29        <h4>Game updates</h4>
30        <div class="card-body" v-for="update in game.updates">
31            <div class="card-title">
32                <h5>\{{ update.event_type }} (\{{ update.minute }}')</h5>
33            </div>
34            <div class="card-text">
35                \{{ update.description }}
36            </div>
37        </div>
38    </div>

You’ll notice we’re using a few Vue.js tags here (v-on, v-for). We’ll be rendering this page using Vue.js. We’ll come back to that later.

Lastly, we’ll add the view for the admin to log in, views/login.hbs:

1<!-- views/login.hbs -->
2    <div class="container">
3        <div class="row justify-content-center">
4            <div class="col-md-8">
5                <div class="card">
6                    <div class="card-header">Login</div>
7    
8                    <div class="card-body">
9                        <form method="POST" action="/login">
10                            <div class="form-group row">
11                                <label for="username" class="col-sm-4 col-form-label text-md-right">Username</label>
12                                <div class="col-md-6">
13                                    <input id="username" class="form-control" name="username" required autofocus>
14                                </div>
15                            </div>
16    
17                            <div class="form-group row">
18                                <label for="password" class="col-md-4 col-form-label text-md-right">Password</label>
19                                <div class="col-md-6">
20                                    <input id="password" type="password" class="form-control" name="password" required>
21                                </div>
22                            </div>
23    
24                            <div class="form-group row mb-0">
25                                <div class="col-md-8 offset-md-4">
26                                    <button type="submit" class="btn btn-primary">
27                                        Login
28                                    </button>
29                                </div>
30                            </div>
31                        </form>
32                    </div>
33                </div>
34            </div>
35        </div>
36    </div>

Let’s create the routes. Replace the contents of your routes/index.js with the following:

1// routes/index.js
2    
3    const express = require('express');
4    const router = express.Router();
5    const passport = require('passport');
6    
7    // see the login form
8    router.get('/login', (req, res, next) => {
9        res.render('login');
10    });
11    
12    // log in
13    router.post('/login',
14        passport.authenticate('local', {failureRedirect: '/login'}),
15        (req, res, next) => {
16            res.redirect('/');
17        });
18    
19    // view all games
20    router.get('/',
21        (req, res, next) => {
22            res.render('index', {games: {}});
23        });
24    
25    // view a game
26    router.get('/games/:id',
27        (req, res, next) => {
28            res.render('index', {game: {}});
29        });
30    
31    // start a game
32    router.post('/games',
33        (req, res, next) => {
34            res.redirect(`/games/${game.id}`);
35        });
36    
37    // post an update for a game
38    router.post('/games/:id',
39        (req, res, next) => {
40            res.json();
41        });
42    
43    // update a game's score
44    router.post('/games/:id/score',
45        (req, res, next) => {
46            res.json();
47        });
48    
49    module.exports = router;

We’re defining seven routes here:

  • The routes to view the login form and to log in
  • The routes to view all ongoing games and a single game
  • The routes to create a game, add an update for a game, or update the game’s score

For now, we’ve only implemented the logic for the first set of routes (login). We’ll come back to the others in a bit.

Implementing the logic

Let’s create the model to map to our database. We have a single model, the Game model:

1// game.js
2    let mongoose = require('mongoose');
3    
4    let Game = mongoose.model('Game', {
5        first_team: String,
6        second_team: String,
7        first_team_score: Number,
8        second_team_score: Number,
9        updates: [{
10            minute: Number,
11            event_type: String,
12            description: String,
13        }],
14    });
15    
16    module.exports = Game;

The updates field of a game will be an array containing each new update posted for the game in reverse chronological order (newest to oldest).

Now, back to our router. We’ll use the Game model to interact with the database as needed. Replace the code in your routes/index.js with the following:

1// routes/index.js
2    
3    const express = require('express');
4    const router = express.Router();
5    const passport = require('passport');
6    const Game = require('./../game');
7    
8    // see the login form
9    router.get('/login', (req, res, next) => {
10        res.render('login');
11    });
12    
13    // log in
14    router.post('/login',
15        passport.authenticate('local', {failureRedirect: '/login'}),
16        (req, res, next) => {
17            res.redirect('/');
18        });
19    
20    // view all games
21    router.get('/',
22        (req, res, next) => {
23            return Game.find({})
24                .then((games) => {
25                    return res.render('index', {games});
26                });
27        });
28    
29    // view a game
30    router.get('/games/:id',
31        (req, res, next) => {
32            return Game.findOne({_id: req.params.id})
33                .then((game) => {
34                    return res.render('game', { game: encodeURI(JSON.stringify(game)) });
35                });
36        });
37    
38    // start a game
39    router.post('/games',
40        (req, res, next) => {
41            return Game.create(req.body)
42                .then((game) => {
43                    return res.redirect(`/games/${game.id}`);
44                });
45        });
46    
47    // post an update for a game
48    router.post('/games/:id',
49        (req, res, next) => {
50            const data = req.body;
51            // This adds the new update to start of the `updates` array
52            // so they are sorted newest-to-oldest
53            const updateQuery = { $push: { updates: { $each: [ data ], $position: 0 } } };
54            return Game.findOneAndUpdate({_id: req.params.id}, updateQuery)
55                .then((game) => {
56                    return res.json(game);
57                });
58        });
59    
60    // update a game's score
61    router.post('/games/:id/score',
62        (req, res, next) => {
63            return Game.findOneAndUpdate({_id: req.params.id}, req.body)
64                .then((game) => {
65                    return res.json(game);
66                });
67        });
68    
69    module.exports = router;

Here’s what is going on:

  • In the home page route, we query the database for a list of all games and send to the view.
  • In the single game route, we retrieve the game’s details and render them.
  • In the start game route, we create a new game and redirect to its page.
  • In the last two routes, we update the game’s details and return the updated values. We use MongoDB’s [$push operator](https://docs.mongodb.com/manual/reference/operator/update/push/) to add the new update on top of older ones.

Completing the frontend app

Now we head back to our frontend. We’re going to pull in Vue and use it to manage the single game view. Add the following code at the end of the single game view (views/game.hbs):

1<!-- views/game.hbs -->
2    
3    
4    <script src="https://cdn.jsdelivr.net/npm/vue@2.5.17/dist/vue.js"></script>
5    <script>
6        const game = JSON.parse(decodeURI("{{ game }}"));
7        var app = new Vue({
8            el: '#main',
9    
10            data: {
11                game,
12                pendingUpdate: {
13                    minute: '',
14                    event_type: '',
15                    description: ''
16                }
17            },
18    
19            methods: {
20                updateGame(event) {
21                    event.preventDefault();
22                    fetch(`/games/${this.game._id}`, {
23                        body: JSON.stringify(this.pendingUpdate),
24                        credentials: 'same-origin',
25                        headers: {
26                            'content-type': 'application/json',
27                            'x-socket-id': window.socketId
28                        },
29                        method: 'POST',
30                    }).then(response => {
31                        console.log(response);
32                        if (response.ok) {
33                            if (!this.game.updates) this.game.updates = [];
34                            this.game.updates.unshift(this.pendingUpdate);
35                            this.pendingUpdate = {};
36                        }
37                    });
38                },
39    
40                updateScore() {
41                    const data = {
42                        first_team_score: this.game.first_team_score,
43                        second_team_score: this.game.second_team_score,
44                    };
45                    fetch(`/games/${this.game._id}/score`, {
46                        body: JSON.stringify(data),
47                        credentials: 'same-origin',
48                        headers: {
49                            'content-type': 'application/json',
50                        },
51                        method: 'POST',
52                    }).then(response => {
53                        console.log(response);
54                    });
55                },
56    
57                updateFirstTeamScore(event) {
58                    this.game.first_team_score = event.target.innerText;
59                    this.updateScore();
60                },
61    
62                updateSecondTeamScore(event) {
63                    this.game.second_team_score = event.target.innerText;
64                    this.updateScore();
65                }
66            }
67        });
68    </script>

Updating the game details in realtime

Sign in to your Pusher dashboard and create a new app. Create a file in the root of your project called .env. Copy your app credentials from the App Keys section and add them to this file:

1# .env
2    PUSHER_APP_ID=your-app-id
3    PUSHER_APP_KEY=your-app-key
4    PUSHER_APP_SECRET=your-app-secret
5    PUSHER_APP_CLUSTER=your-app-cluster

Now we’ll trigger a new Pusher event on the backend when a game’s details change. Modify the code in your routes/index.js so it looks like this:

1// routes/index.js
2    
3    const express = require('express');
4    const router = express.Router();
5    const passport = require('passport');
6    const Game = require('./../models/game');
7    const Pusher = require('pusher');
8    const pusher = new Pusher({
9        appId: process.env.PUSHER_APP_ID,
10        key: process.env.PUSHER_APP_KEY,
11        secret: process.env.PUSHER_APP_SECRET,
12        cluster: process.env.PUSHER_APP_CLUSTER
13    });
14    
15    // see the login form
16    router.get('/login', (req, res, next) => {
17        res.render('login');
18    });
19    
20    // log in
21    router.post('/login',
22        passport.authenticate('local', {failureRedirect: '/login'}),
23        (req, res, next) => {
24            res.redirect('/');
25        });
26    
27    // view all games
28    router.get('/',
29        (req, res, next) => {
30            return Game.find({})
31                .then((games) => {
32                    return res.render('index', { games });
33                });
34        });
35    
36    // view a game
37    router.get('/games/:id',
38        (req, res, next) => {
39            return Game.findOne({_id: req.params.id})
40                .then((game) => {
41                    return res.render('game', {
42                        game: encodeURI(JSON.stringify(game)),
43                        key: process.env.PUSHER_APP_KEY,
44                        cluster: process.env.PUSHER_APP_CLUSTER,
45                    });
46                });
47        });
48    
49    // start a game
50    router.post('/games',
51        (req, res, next) => {
52            return Game.create(req.body)
53                .then((game) => {
54                    return res.redirect(`/games/${game.id}`);
55                });
56        });
57    
58    // post an update for a game
59    router.post('/games/:id',
60        (req, res, next) => {
61            const data = req.body;
62            // This adds the new update to start of the `updates` array
63            // so they are sorted newest-to-oldest
64            const updateQuery = { $push: { updates: { $each: [ data ], $position: 0 } } };
65            return Game.findOneAndUpdate({_id: req.params.id}, updateQuery)
66                .then((game) => {
67                    pusher.trigger(`game-updates-${game._id}`, 'event', data, req.headers['x-socket-id']);
68                    return res.json(data);
69                });
70        });
71    
72    // update a game's score
73    router.post('/games/:id/score',
74        (req, res, next) => {
75        const data = req.body;
76            return Game.findOneAndUpdate({_id: req.params.id}, data)
77                .then((game) => {
78                    pusher.trigger(`game-updates-${game._id}`, 'score', data, req.headers['x-socket-id']);
79                    return res.json(data);
80                });
81        });
82    
83    module.exports = router;

The major changes we’ve made here are:

  • When rendering the single game view, we pass on the necessary Pusher credentials (the key and the cluster) so the frontend can connect to Pusher and get updated of changes to the game
  • Whenever there’s an update to a game, we trigger an event on a channel tied to the ID of the game. The event will either be “update” or “score”.
  • We’re also passing in the Pusher socket ID so the event doesn’t get sent to the client it’s coming from (see here to learn more).

Now let’s update our frontend to respond to these changes. Add the following code to the end of the single game view:

1// views/game.hbs
2    
3    
4    <script src="https://js.pusher.com/4.2/pusher.min.js"></script>
5    <script>
6        Pusher.logToConsole = true;
7    
8        const pusher = new Pusher("{{ key }}", {
9            cluster: "{{ cluster }}"
10        });
11        pusher.connection.bind('connected', () => {
12            window.socketId = pusher.connection.socket_id;
13        });
14        pusher.subscribe(`game-updates-${app.game._id}`)
15                .bind('event', (data) => {
16                    app.game.updates.unshift(data);
17                })
18                .bind('score', (data) => {
19                    app.game.first_team_score = data.first_team_score;
20                    app.game.second_team_score = data.second_team_score;
21                });
22    </script>

Here we include the Pusher JavaScript library and listen for the events on the game’s channel, and update the game as needed. Vue will handle re-rendering the page for us.

Now let’s see the app in action. Start your MongoDB server by running mongod. Note that on Linux or macOS, you might need to run it as sudo.

Then start your app on http://localhost:3000 by running:

    npm start

Visit /login and log in as admin (password: “secret”).

Use the form on the home page to start a new game. You’ll be redirected to that game’s page. Open that same URL in an incognito window (so you can view it as a logged-out user).

Make changes to the game’s score by clicking on the scores and entering a new value. The score will be updated once you click on something else.

You can also post updates by using the form on the page. In both cases, you should see the scores and game updates in the incognito window update in realtime.

Conclusion

In today’s article, we’ve leveraged Pusher’s API to build a lightweight but fun experience that allows anyone to follow the sports action in realtime. The source code of the completed application is available on GitHub.