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:
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 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 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.
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.
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:
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.
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:
[$push operator](https://docs.mongodb.com/manual/reference/operator/update/push/)
to add the new update on top of older ones.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>
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:
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.
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.