Build a live game updates site with Express
You will need Node 8.10+ and MongoDB 3.4+ installed on your machine.
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:
Prerequisites
- Node.js 8.10.0 or higher
- MongoDB 3.4 or higher.
- A Pusher account.
Setting up
We’ll create a new app using the Express application generator:
npx express-generator --view=hbs live-game-updates-express
cd live-game-updates-express
npm install
Note: if the line with
npx
throws an error about thenpx
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 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 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:
// app.js
require('dotenv').config();
const express = require('express');
const path = require('path');
const logger = require('morgan');
const session = require('express-session');
const passport = require('passport');
const LocalStrategy = require('passport-local').Strategy;
passport.use(new LocalStrategy((username, password, done) => {
if (username === 'admin' && password === 'secret') {
return done(null, {username});
}
return done(null, null)
})
);
passport.serializeUser((user, cb) => cb(null, user.username));
passport.deserializeUser((username, cb) => cb(null, { username }));
const app = express();
require('mongoose').connect('mongodb://localhost/live-game-updates-express');
// view engine setup
app.set('views', path.join(__dirname, 'views'));
app.set('view engine', 'hbs');
app.use(logger('dev'));
app.use(express.json());
app.use(express.urlencoded({extended: false}));
app.use(express.static(path.join(__dirname, 'public')));
app.use(session({ secret: 'anything' }));
app.use(passport.initialize());
app.use(passport.session());
app.use((req, res, next) => {
res.locals.user = req.user;
next();
});
app.use('/', require('./routes/index'));
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:
<!-- views/layout.hbs -->
<!DOCTYPE html>
<html>
<head>
<title>Live Game Updates</title><!-- Latest compiled and minified CSS -->
<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.7/css/bootstrap.min.css"
integrity="sha384-BVYiiSIFeK1dGmJRAkycuHAHRg32OmUcww7on3RYdg4Va+PmSTsz/K68vbdEjh4u" crossorigin="anonymous">
</head>
<body>
<main class="py-4">
{{{body}}}
</main>
</body>
</html>
Now, replace the contents of index.hbs
file with the following:
<!-- views/home.hbs -->
<div class="container">
<h2>Ongoing games</h2>
{{#if user }}
<form method="post" action="/games" class="form-inline">
<input class="form-control" name="first_team" placeholder="First team" required>
<input class="form-control" name="second_team" placeholder="Second team" required>
<input type="hidden" name="first_team_score" value="0">
<input type="hidden" name="second_team_score" value="0">
<button type="submit" class="btn btn-primary">Start new game</button>
</form>
{{/if}}
<br>
{{#each games }}
<a class="card bg-dark" href="/games/{{ this.id }}">
<div class="card-body">
<div class="card-title">
<h4>{{ this.first_team }} {{ this.first_team_score }} - {{ this.second_team_score }} {{ this.second_team }}</h4>
</div>
</div>
</a>
{{/each}}
</div>
The next view is that of a single game. Put the following code in the file views/game.hbs
:
<!-- views/game.hbs -->
<div id="main" class="container" xmlns:v-on="http://www.w3.org/1999/xhtml">
<h2>\{{ game.first_team }}
<span {{#if user}} contenteditable {{/if}} v-on:blur="updateFirstTeamScore">\{{ game.first_team_score }}</span>
-
<span {{#if user}} contenteditable {{/if}} v-on:blur="updateSecondTeamScore">\{{ game.second_team_score }}</span>
\{{ game.second_team }}</h2>
{{#if user }}
<div class="card">
<div class="card-body">
<form v-on:submit="updateGame">
<h6>Post a new game update</h6>
<input class="form-control" type="number" v-model="pendingUpdate.minute"
placeholder="In what minute did this happen?">
<input class="form-control" placeholder="Event type (goal, foul, injury, booking...)"
v-model="pendingUpdate.event_type">
<input class="form-control" placeholder="Add a description or comment..."
v-model="pendingUpdate.description">
<button type="submit" class="btn btn-primary">Post update</button>
</form>
</div>
</div>
{{/if}}
<br>
<h4>Game updates</h4>
<div class="card-body" v-for="update in game.updates">
<div class="card-title">
<h5>\{{ update.event_type }} (\{{ update.minute }}')</h5>
</div>
<div class="card-text">
\{{ update.description }}
</div>
</div>
</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
:
<!-- views/login.hbs -->
<div class="container">
<div class="row justify-content-center">
<div class="col-md-8">
<div class="card">
<div class="card-header">Login</div>
<div class="card-body">
<form method="POST" action="/login">
<div class="form-group row">
<label for="username" class="col-sm-4 col-form-label text-md-right">Username</label>
<div class="col-md-6">
<input id="username" class="form-control" name="username" required autofocus>
</div>
</div>
<div class="form-group row">
<label for="password" class="col-md-4 col-form-label text-md-right">Password</label>
<div class="col-md-6">
<input id="password" type="password" class="form-control" name="password" required>
</div>
</div>
<div class="form-group row mb-0">
<div class="col-md-8 offset-md-4">
<button type="submit" class="btn btn-primary">
Login
</button>
</div>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
Let’s create the routes. Replace the contents of your routes/index.js
with the following:
// routes/index.js
const express = require('express');
const router = express.Router();
const passport = require('passport');
// see the login form
router.get('/login', (req, res, next) => {
res.render('login');
});
// log in
router.post('/login',
passport.authenticate('local', {failureRedirect: '/login'}),
(req, res, next) => {
res.redirect('/');
});
// view all games
router.get('/',
(req, res, next) => {
res.render('index', {games: {}});
});
// view a game
router.get('/games/:id',
(req, res, next) => {
res.render('index', {game: {}});
});
// start a game
router.post('/games',
(req, res, next) => {
res.redirect(`/games/${game.id}`);
});
// post an update for a game
router.post('/games/:id',
(req, res, next) => {
res.json();
});
// update a game's score
router.post('/games/:id/score',
(req, res, next) => {
res.json();
});
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:
// game.js
let mongoose = require('mongoose');
let Game = mongoose.model('Game', {
first_team: String,
second_team: String,
first_team_score: Number,
second_team_score: Number,
updates: [{
minute: Number,
event_type: String,
description: String,
}],
});
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:
// routes/index.js
const express = require('express');
const router = express.Router();
const passport = require('passport');
const Game = require('./../game');
// see the login form
router.get('/login', (req, res, next) => {
res.render('login');
});
// log in
router.post('/login',
passport.authenticate('local', {failureRedirect: '/login'}),
(req, res, next) => {
res.redirect('/');
});
// view all games
router.get('/',
(req, res, next) => {
return Game.find({})
.then((games) => {
return res.render('index', {games});
});
});
// view a game
router.get('/games/:id',
(req, res, next) => {
return Game.findOne({_id: req.params.id})
.then((game) => {
return res.render('game', { game: encodeURI(JSON.stringify(game)) });
});
});
// start a game
router.post('/games',
(req, res, next) => {
return Game.create(req.body)
.then((game) => {
return res.redirect(`/games/${game.id}`);
});
});
// post an update for a game
router.post('/games/:id',
(req, res, next) => {
const data = req.body;
// This adds the new update to start of the `updates` array
// so they are sorted newest-to-oldest
const updateQuery = { $push: { updates: { $each: [ data ], $position: 0 } } };
return Game.findOneAndUpdate({_id: req.params.id}, updateQuery)
.then((game) => {
return res.json(game);
});
});
// update a game's score
router.post('/games/:id/score',
(req, res, next) => {
return Game.findOneAndUpdate({_id: req.params.id}, req.body)
.then((game) => {
return res.json(game);
});
});
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
):
<!-- views/game.hbs -->
<script src="https://cdn.jsdelivr.net/npm/vue@2.5.17/dist/vue.js"></script>
<script>
const game = JSON.parse(decodeURI("{{ game }}"));
var app = new Vue({
el: '#main',
data: {
game,
pendingUpdate: {
minute: '',
event_type: '',
description: ''
}
},
methods: {
updateGame(event) {
event.preventDefault();
fetch(`/games/${this.game._id}`, {
body: JSON.stringify(this.pendingUpdate),
credentials: 'same-origin',
headers: {
'content-type': 'application/json',
'x-socket-id': window.socketId
},
method: 'POST',
}).then(response => {
console.log(response);
if (response.ok) {
if (!this.game.updates) this.game.updates = [];
this.game.updates.unshift(this.pendingUpdate);
this.pendingUpdate = {};
}
});
},
updateScore() {
const data = {
first_team_score: this.game.first_team_score,
second_team_score: this.game.second_team_score,
};
fetch(`/games/${this.game._id}/score`, {
body: JSON.stringify(data),
credentials: 'same-origin',
headers: {
'content-type': 'application/json',
},
method: 'POST',
}).then(response => {
console.log(response);
});
},
updateFirstTeamScore(event) {
this.game.first_team_score = event.target.innerText;
this.updateScore();
},
updateSecondTeamScore(event) {
this.game.second_team_score = event.target.innerText;
this.updateScore();
}
}
});
</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:
# .env
PUSHER_APP_ID=your-app-id
PUSHER_APP_KEY=your-app-key
PUSHER_APP_SECRET=your-app-secret
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:
// routes/index.js
const express = require('express');
const router = express.Router();
const passport = require('passport');
const Game = require('./../models/game');
const Pusher = require('pusher');
const pusher = new Pusher({
appId: process.env.PUSHER_APP_ID,
key: process.env.PUSHER_APP_KEY,
secret: process.env.PUSHER_APP_SECRET,
cluster: process.env.PUSHER_APP_CLUSTER
});
// see the login form
router.get('/login', (req, res, next) => {
res.render('login');
});
// log in
router.post('/login',
passport.authenticate('local', {failureRedirect: '/login'}),
(req, res, next) => {
res.redirect('/');
});
// view all games
router.get('/',
(req, res, next) => {
return Game.find({})
.then((games) => {
return res.render('index', { games });
});
});
// view a game
router.get('/games/:id',
(req, res, next) => {
return Game.findOne({_id: req.params.id})
.then((game) => {
return res.render('game', {
game: encodeURI(JSON.stringify(game)),
key: process.env.PUSHER_APP_KEY,
cluster: process.env.PUSHER_APP_CLUSTER,
});
});
});
// start a game
router.post('/games',
(req, res, next) => {
return Game.create(req.body)
.then((game) => {
return res.redirect(`/games/${game.id}`);
});
});
// post an update for a game
router.post('/games/:id',
(req, res, next) => {
const data = req.body;
// This adds the new update to start of the `updates` array
// so they are sorted newest-to-oldest
const updateQuery = { $push: { updates: { $each: [ data ], $position: 0 } } };
return Game.findOneAndUpdate({_id: req.params.id}, updateQuery)
.then((game) => {
pusher.trigger(`game-updates-${game._id}`, 'event', data, req.headers['x-socket-id']);
return res.json(data);
});
});
// update a game's score
router.post('/games/:id/score',
(req, res, next) => {
const data = req.body;
return Game.findOneAndUpdate({_id: req.params.id}, data)
.then((game) => {
pusher.trigger(`game-updates-${game._id}`, 'score', data, req.headers['x-socket-id']);
return res.json(data);
});
});
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:
// views/game.hbs
<script src="https://js.pusher.com/4.2/pusher.min.js"></script>
<script>
Pusher.logToConsole = true;
const pusher = new Pusher("{{ key }}", {
cluster: "{{ cluster }}"
});
pusher.connection.bind('connected', () => {
window.socketId = pusher.connection.socket_id;
});
pusher.subscribe(`game-updates-${app.game._id}`)
.bind('event', (data) => {
app.game.updates.unshift(data);
})
.bind('score', (data) => {
app.game.first_team_score = data.first_team_score;
app.game.second_team_score = data.second_team_score;
});
</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.
9 November 2018
by Shalvah Adebayo