Codementor Events

Build a Multiplayer Quiz Game With Vue.js

Published May 04, 2018
Build a Multiplayer Quiz Game With Vue.js

As developers, deploying applications and websites can be a pain point at times and we generally tend to prefer solutions that are easy and scalable.

Hosting solutions that require SSHing and having to make configurations and a million things to do before deploying can (and will) get tedious after a while. This is where Firebase Hosting comes in.

We’ll build a static website and deploy it to the web using Firebase Hosting. We are going to build a realtime multiplayer trivia game, the game is going to work like the popular multiplayer game QuizUp.

Here’s how the game is going to work. The questions are going to be gotten from the lyrics from Hamilton: An American Musical and users have to answer correctly the name of the character in the musical who sang that line. The game tests the user’s knowledge of Hamilton and users can play for as long as they like. See a demo below.

https://vimeo.com/230121610

Our game will be built with Vue.js and will use Pusher’s Client Events and Presence Channels to make sure a user’s move is shown realtime.

What are Presence Channels?

Pusher’s Presence channels expose the additional feature of an awareness of who is subscribed to that channel. This gives developers the ability to build features like chat rooms, collaborators on a document and multiplayer games.

All Presence Channels must be subscribed with the prefix of presence- and as with private channels a HTTP Request is made to a configurable authentication URL to determine if the current user has permissions to access the channel.

What are Client Events?

Client events are simply a way in which events can be triggered directly from the client-side as opposed to triggering from the backend server. Client Events are used in instances where some actions may not need validation or persistence and can go directly via the socket to all the other clients connected to the channel.

In our case, we use the Client Events to update a user on the current score of the other user playing the game.

Client Events have a number of enforced restrictions to ensure that the user subscribing to the channel is an authenticated user:

  1. Client events must be enabled for the application. You can do this in the Settings tab for your app within the Pusher dashboard.
  2. The user must be subscribed to the channel that the event is being triggered on.
  3. Client events can only be triggered on private and presence channels because they require authentication.
  4. Client events must be prefixed by client-. Events with any other prefix will be rejected by the Pusher server, as will events sent to channels to which the client is not subscribed.

You can read more on Client Events by going through the documentation.

Setting Up Pusher

Log in to your dashboard (or create a free account if you don’t already have one) and create a new app. Copy your app_id, key, secret and cluster and store them somewhere as we’ll be needing them later. One more thing. In your dashboard, navigate to the App Settings tab and make sure the Enable Client Events checkbox is checked. This lets clients communicate directly with each other.

Initializing the Vue.js Application

Vue.js is the preferred JavaScript framework to build the game because of its popularity and low barrier to getting started. There are some Vue.js tutorials on Pusher which you can read here, here and here.

We’re going to be using the vue-cli to scaffold a Vue.js project. The vue-cli is a simple CLI for scaffolding Vue.js projects. It ships with many templates like webpack, browserify, pwa and simple. We’ll install vue-cli and then use it to bootstrap the app using the webpack template, with the following commands:

npm install -g vue-cli     
vue init webpack hamiltonlyrics

This creates a Vue.js app inside the a folder titled hamiltonlyrics. Navigate into the folder and run the command npm run dev to see the Vue.js application.

Setting up a Node.js Server

As explained above, Client Events require authentication to make sure a user is subscribed to the channel. Therefore, we are going to create a Node.js server so that Client Events can have an authentication route.

Let’s install the modules we’ll need for the Node.js server.

npm i express body-parser pusher

In the root of the project directory, create a file named server.js and type in the following code:

// server.js
    const express = require('express')
    const path = require('path')
    const bodyParser = require('body-parser')
    const app = express()
    const Pusher = require('pusher')
    const crypto = require('crypto')

    //initialize Pusher with your appId, key and secret
    const pusher = new Pusher({
      appId: 'APP_ID',
      key: 'APP_KEY',
      secret: 'APP_SECRET',
      cluster: 'YOUR_CLUSTER',
      encrypted: true
    })

    // Body parser middleware
    app.use(bodyParser.json())
    app.use(bodyParser.urlencoded({ extended: false }))

    // The code below helps to fix any potential CORS issue.
    app.use((req, res, next) => {
      // Website you wish to allow to connect
      res.setHeader('Access-Control-Allow-Origin', '*')
      // Request methods you wish to allow
      res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS, PUT, PATCH, DELETE')
      // Request headers you wish to allow
      res.setHeader('Access-Control-Allow-Headers', 'X-Requested-With,content-type')
      // Set to true if you need the website to include cookies in the requests sent
      // to the API (e.g. in case you use sessions)
      res.setHeader('Access-Control-Allow-Credentials', true)
      // Pass to next layer of middleware
      next()
    })

    // Index API route for the Express app
    app.get('/', (req, res) => {
      res.send('Welcome')
    })

    // API route used by Pusher as a way of authenticating users
    app.post('/pusher/auth', (req, res) => {
      let socketId = req.body.socket_id
      let channel = req.body.channel_name
      // Generate a random string and use as presence channel user_id
      let presenceData = {
        user_id: crypto.randomBytes(16).toString('hex')
      }
      let auth = pusher.authenticate(socketId, channel, presenceData)
      res.send(auth)
    })

    // Set port to be used by Node.js
    app.set('port', (5000))

    app.listen(app.get('port'), () => {
      console.log('Node app is running on port', app.get('port'))
    })

In the code block above, Pusher is initialized with the dashboard credentials. The /pusher/auth route is also created.

Now we can simply run node server.js and the Node.js app should be up and running. Before we go on let’s add the command above to the existing scripts object in the package.json file so we don’t have to type in the command every time. Open up the package.json file and edit the dev line inside the scripts object with the one below.

"dev": "nodemon server.js & node build/dev-server.js"

Building the game

Let’s get started on building the game. We’ll be working with two files throughout the course of this tutorial, a Home.vue file and a ChannelDetails.vue file.

Navigate to the components folder inside the src folder and create a new file called ChannelDetails.vue. This will contain the JavaScript code that establishes a connection to Pusher from the client side. Open the file and type in the following code:

<script>
      import Pusher from 'pusher-js'
      const pusher = new Pusher('APP_KEY', {
        cluster: 'YOUR_CLUSTER',
        encrypted: true,
        authEndpoint: 'http://localhost:5000/pusher/auth'
      })
      export default ({
        getPresenceID () {
          // This function checks the address bar of the browser for params
          let getQueryString = (field, url) => {
            let href = url ? url : window.location.href
            let reg = new RegExp('[?&]' + field + '=([^&#]*)', 'i')
            let string = reg.exec(href)
            return string ? string[1] : null
          }
          // Appends 'presence' to the result
          let id = getQueryString('id')
          id = 'presence-' + id
          return id
        },
        subscribeToPusher () {
          let presenceid = this.getPresenceID()
          let channel = pusher.subscribe(presenceid)
          return channel
        }
      })
    </script>

If you use ESLint, you should be getting a warning that the Pusher JS library has not been installed. That can be installed by running this command npm i pusher-js. So what’s happening up there?

Firstly, Pusher is imported and a connection is established using credentials like APP_KEY, and CLUSTER. An authEndpoint is added to the Pusher instance. The authEndpoint is the endpoint Pusher uses to authenticate users.

Secondly, there are two functions above which are being exported. The first function getPresenceID() checks the address bar of the browser for URL parameters and then appends presence to the result. This is done so that the channel name will always have a prefix of presence- since we are using Presence channels.

The second function uses the result of the getPresenceID() function and uses it to subscribe to a channel (a Presence Channel to be specific).

The next thing to do is to start writing code for the game itself. Open up the App.vue file inside the src folder and make sure its content is similar to the code below:

<template>
      <div id="app">
        <router-view></router-view>
      </div>
    </template>

    <script>
    export default {
      name: 'app'
    }
    </script>

    <style>a
      html {
        background: #7fd4d3;
      }
      body {
        background: #7fd4d3;
        padding: 20px;
      }
      #app {
        height: 100vh;
        -webkit-font-smoothing: antialiased;
        -moz-osx-font-smoothing: grayscale;
        text-align: center;
        color: #fff;
      }
      .fade-enter-active, .fade-leave-active {
        transition: opacity .5s
      }
      .fade-enter, .fade-leave-to /* .fade-leave-active in <2.1.8 */ {
        opacity: 0
      }
    </style>

Navigate to the src/components directory and you should see a Hello.vue file. You can either delete that file or rename to Home.vue as we will be needing a Home.vue file inside the components folder. Open up the file and type in/replace with the code below:

<script>
      // Import ChannelDetails component created above
      import ChannelDetails from '@/components/ChannelDetails'
      // An array that holds the lyrics questions and their correct answers. All questions can be seen here https://gist.github.com/yomete/2d851c2adc008a9763a0db9f85879083
      const lyrics = [
        {
          lyric: 'When he was ten his father split, full of it, debt-ridden. Two years later, see Alex and his mother bed-ridden. Half-dead sittin\' in their own sick, the scent thick',
          options: [{name: 'Aaron Burr', correct: false}, {name: 'James Madison', correct: false}, {name: 'John Laurens', correct: false}, {name: 'Eliza Hamilton', correct: true}],
          answer: 'Eliza Hamilton'
        },
        {
          lyric: 'I am sailing off to London. I’m accompanied by someone who always pays. I have found a wealthy husband. Who will keep me in comfort for all my days. He is not a lot of fun, but there’s no one',
          options: [{name: 'Eliza', correct: false}, {name: 'Peggy', correct: false}, {name: 'Angelica', correct: true}, {name: 'Maria', correct: false}],
          answer: 'Angelica'
        }
      ]
      export default {
        name: 'home',
        data () {
          return {
            // This holds the current presence-id
            presenceid: null,
            // This checks if a question has been answered, default to false
            hasAnswered: false,
            // This holds the current question
            question: null,
            // This holds the options for the current question
            options: null,
            // This holds the correct answer for the current question
            correctanswer: null,
            // This is used for a countdown timer
            count: null,
            // Number of players in the game
            players: 1,
            // This checks if there's a second player, it becomes true when players = 2
            secondplayer: false,
            // This holds the player data for both players
            playerdata: {
              one: {
                id: null,
                score: 0,
                userid: null
              },
              two: {
                id: null,
                score: 0,
                userid: null
              }
            },
            // This holds the userid for the current player
            userid: null,
            // This holds the current URL of the game
            url: null
          }
        },
        created () {
          this.fetchData()
        },
        methods: {
          fetchData () {},
          getUniqueId () {}
          checkPresenceID () {}
          checkAnswer (item) {}
          getRandomQuestions (array, count) {}
          getNewQuestion () {}
        } 
      }
    </script>

    <!-- Add "scoped" attribute to limit CSS to this component only -->
    <style scoped>
      .home {
        display: flex;
        align-items: center;
        justify-content: center;
        height: 100vh;
      }
      h1 {
        font-size: 3rem;
        font-weight: bold;
      }
      p {
        font-size: 1.5rem;
        margin: 0 0 20px 0;
      }
      .play--button {
        background-color: white;
        color: #7fd4d3;
        font-weight: bold;
        border-radius: 20px;
        letter-spacing: 1px;
        padding: 20px;
        transition: all .3s ease;
        text-shadow: 0 1px 3px rgba(36,180,126,.4);
        text-transform: uppercase;
        box-shadow: 0 4px 6px rgba(50,50,93,.11), 0 1px 3px rgba(0,0,0,.08);
      }
      .play--button:hover {
        background-color: white;
        color: #7fd4d3;
        transform: translateY(-1px);
        box-shadow: 0 7px 14px rgba(50,50,93,.1), 0 3px 6px rgba(0,0,0,.08);
      }
      .fade-enter-active, .fade-leave-active {
        transition: opacity .5s
      }
      .fade-enter, .fade-leave-to {
        opacity: 0
      }
      a {
        color: #fff;
      }
      p {
        color: #fff;
      }
      h1 {
        font-size: 3rem;
        font-weight: bold;
        text-align: center;
      }
      .fade-enter-active, .fade-leave-active {
        transition: opacity .5s
      }
      .fade-enter, .fade-leave-to /* .fade-leave-active in <2.1.8 */ {
        opacity: 0
      }
      .play--button {
        background-color: white;
        color: #7fd4d3;
        font-weight: bold;
        border-radius: 20px;
        letter-spacing: 1px;
        padding: 20px;
        transition: all .3s ease;
        text-shadow: 0 1px 3px rgba(36,180,126,.4);
        text-transform: uppercase;
        box-shadow: 0 4px 6px rgba(50,50,93,.11), 0 1px 3px rgba(0,0,0,.08);
        position: absolute;
        top: 20px;
        right: 20px;
        z-index: 5;
      }
      .play--button:hover {
        background-color: white;
        color: #7fd4d3;
        transform: translateY(-1px);
        box-shadow: 0 7px 14px rgba(50,50,93,.1), 0 3px 6px rgba(0,0,0,.08);
      }
      .hamilton--header--text {
        margin-top: 50px;
      }
      .hamilton--inner {
        margin-top: 20px;
      }
      .hamilton--inner .left{
        text-align: left;
      }
      .hamilton--inner .right{
        text-align: right;
      }
      .title {
        font-weight: bold;
      }
      .hamilton--lyrics--text {
        width: 600px;
        margin: 0 auto;
      }
      .hamilton--lyrics--text p {
        font-weight: bold;
      }
      .hamilton--answers a{
        display: block;
        border: 3px solid white;
        border-radius: 50px;
        margin: 20px auto;
        width: 500px;
        padding: 10px;
      }
      .wronganswer {
        background-color: #ec6969;
        border: none !important;
        opacity: 0.4;
        transition: background-color 0.5s ease;
      }
      .correctanswer {
        background-color: #00c4a7;
        border: none !important;
        transition: background-color 0.5s ease;
      }
    </style>

In the code block above, we set up the foundation for the game and how it’s going to work. Inside the <style>tag and inside the <script> tag, there are a couple of functions that we will need to create and add logic to.

Let’s take a look at the functions we need to create.

fetchData()

This function is called inside the created hook and that means it will always be called whenever the instance has been created. Let’s write the code for this function.

// Sets the data instance presenceid variable to the result of the getUniqueId function
    this.presenceid = this.getUniqueId()
    // This checks if there's no presence ID in the URL via the checkPresenceID function and appends the presenceid to the current URL so that we can have the URL end with a parameter like this https://hamilton-lyrics.firebaseapp.com/#/?id=agbew0gz
    if (!this.checkPresenceID()) {
      var separator = (window.location.href.indexOf('?') === -1) ? '?' : '&'
      window.location.href = window.location.href + separator + this.presenceid
    }
    // Sets the data instance url variable to the current URL.
    this.url = window.location.href
    // Gets a new question via the getNewQuestion() function
    this.getNewQuestion()
    // The channel variable is set to to the subscribeToPusher function in ChannelDetails.
    let channel = ChannelDetails.subscribeToPusher()

    // The pusher:member_added event is triggered when a user joins a channel. We increase the number of players by one and also set the secondplayer boolean to true.
    channel.bind('pusher:member_added', members => {
      this.players += 1
      this.secondplayer = true
    })

    // Once a subscription has been made to a presence channel, an event is triggered with a members iterator. 
    channel.bind('pusher:subscription_succeeded', members => {
      // This checks if its just one player online and sets them up as player one and the required info for the game
      if (members.count === 1 && !this.playerdata.one.id) {
        this.playerdata.one.id = members.myID
        this.playerdata.one.userid = 1
        this.userid = 1
      // This checks if there's a player online already and sets the new player as player two.
      } else if (members.count === 2) {
        this.secondplayer = true
        this.playerdata.two.id = members.myID
        this.playerdata.two.userid = 2
        this.userid = 2
      }
    })
    // The pusher:member_removed is triggered when a user leaves a channel. We decrease the number of players by one and also set the secondplayer boolean to false.
    channel.bind('pusher:member_removed', member => {
      this.players -= 1
      if (member.count === 1) {
        this.secondplayer = false
      }
    })
    // This function receives new data from Pusher and updates the exisiting scores. This is what updates each player's score in realtime.
    channel.bind('client-send', (data) => {
      if (this.userid === 1) {
        this.playerdata.two.score = data.data.two.score
      } else if (this.userid === 2) {
        this.playerdata.one.score = data.data.one.score
      }
    })

getUniqueId()

This function simply generates random alphanumeric characters and adds a prefix of id= to the result.

getUniqueId () { return 'id=' + Math.random().toString(36).substr(2, 8)}

checkPresenceId()

This function checks the address bar of the browser for URL parameters, in this case, any parameter that starts with ?id= prefix and then returns the alphanumeric character at the end of the prefix. For example, this URL https://hamilton-lyrics.firebaseapp.com/#/?id=agbew0gz will return agbew0gz.

checkPresenceID () {
      let getQueryString = (field, url) => {
        let href = url ? url : window.location.href
        let reg = new RegExp('[?&]' + field + '=([^&#]*)', 'i')
        let string = reg.exec(href)
        return string ? string[1] : null
      }
      let id = getQueryString('id')
      return id
    }

checkAnswer()

This function is used to check if the answer chosen is correct or incorrect. If the chosen answer is correct, 10 points will be added to the current score and if the answer is incorrect, 10 points will be deducted. The score is also sent to other subscribers of the channel via the channel.trigger() function. At the end of it all, a new question is gotten via the getNewQuestion() function.

checkAnswer (item) {
      let channel = ChannelDetails.subscribeToPusher()
      this.hasAnswered = true
      if (item.name === this.correctanswer) {
        if (this.userid === 1) {
          this.playerdata.one.score += 10
        } else if (this.userid === 2) {
          this.playerdata.two.score += 10
        }
      } else {
        if (this.userid === 1) {
          this.playerdata.one.score = Math.max(0, this.playerdata.one.score -= 10)
        } else if (this.userid === 2) {
          this.playerdata.two.score = Math.max(0, this.playerdata.two.score -= 10)
        }
      }
      channel.trigger('client-send', {data: this.playerdata})
      this.count = 3
      let countdown = setInterval(() => {
        this.count -= 1
        if (this.count === 0) {
          clearInterval(countdown)
          this.getNewQuestion()
        }
      }, 1000)
    }

getRandomQuestions()

This function is used to select questions randomly from the lyrics array that holds the various questions. It takes in two arguments, array and count. array would be the array we are trying to pick a random item from (lyrics) and count is the number of item to be chosen (1 item). This function is called in the getNewQuestion()function below.

getRandomQuestions (array, count) {
      let length = array.length
      let randomIndexes = []
      let randomItems = []
      let index, item

      count = count | 1

      while (count) {
        index = Math.floor(Math.random() * length)
        if (randomIndexes.indexOf(index) === -1) {
          count--
          randomIndexes.push(index)
        }
      }

      randomIndexes.forEach((index) => {
        item = array.slice(index, index + 1).pop()
        randomItems.push(item)
      })

      if (randomItems.length === 1) {
        return randomItems.pop()
      } else {
        return randomItems
      }
    }

getNewQuestion()

This function is used to get a new question for the game. It utilizes the getRandomQuestions() to get a new random question and sets it to the question variable. It also uses the question’s data in the question variable to initialize the various data instances.

getNewQuestion () {
      let question = this.getRandomQuestions(lyrics, 1)
      this.question = question
      this.options = question.options
      this.correctanswer = question.answer
      this.hasAnswered = false
    }

We are done with the functions. Let’s create the template tag and write the HTML code that will display the view for the game.

<template>
      <transition name="fade">
        <div class="home" v-if="!secondplayer">
          <div class="inner">
            <h1>Do you know your Hamilton Lyrics?</h1>
            <p>Test your knowledge of Hamilton: An American Musical by guessing who sang what lyric.</p>
            <p>Invite a second player by sending them this link {{url}}.</p>
          </div>
        </div>
        <div class="play" v-if="secondplayer">
          <div>
            <div class="container hamilton--header--text">
              <h1>Do you know your Hamilton Lyrics?</h1>

              <div class="columns hamilton--inner">
                <div class="column is-half left">
                  <p class="title">User 1</p>
                  <p class="subtitle">Total Score: {{playerdata.one.score}}</p>
                </div>
                <div v-if="secondplayer" class="column is-half right">
                  <p class="title">User 2</p>
                  <p class="subtitle">Total Score: {{playerdata.two.score}}</p>
                </div>
              </div>

              <div class="hamilton--lyrics--text">
                <p>{{question.lyric}}
                </p>
                <div class="hamilton--answers">
                  <a v-bind:class="{ 'wronganswer': hasAnswered && !item.correct, 'correctanswer': hasAnswered && item.correct}" @click="checkAnswer(item)" v-for="(item, index) in options">{{item.name}}</a>
                </div>
              </div>
            </div>
          </div>
        </div>
      </transition>
    </template>

In the code above, we enclose everything in a transition tag with an attribute of fade. There are two divsections that display conditionally. The div tag with a class of home is shown when there’s just one player online and the div tag with a class of play is only shown when two players are online.

At the end of it all, your Home.vue file should look like this.

Setting up Firebase Hosting

Now that our application is ready and working well, let’s deploy the application using Firebase Hosting.

Getting started with Firebase Hosting is straightforward. Go over to console.firebase.google.com and create a new account or sign in if you already have an account.

Since this is a platform hosted by Google, you’ll need a Gmail account to be able to sign up and use the Firebase Console

Your dashboard should look like this (if you’re a new user). Let’s add a new project by clicking on the Add Project button.

That opens up a modal box that asks you to give your project a name and also choose your region. Once that’s done, you should be redirected to the project’s dashboard which looks like this.

The dashboard menu on the left shows all the Firebase services you can use in your application. Before we start deploying apps with Firebase Hosting, we need to install the Firebase CLI using npm.

npm install -g firebase-tools

If you’ve previously installed Firebase command line tools, run the install command again to make sure you have the latest version. Once the Firebase CLI has been successfully installed, we can deploy apps to Firebase Hosting with a single command.

Next step is to sign in to Google from the terminal so Firebase knows which account to use. Run the command firebase login in your terminal. This process takes you to a login page in a browser where you enter your Google credentials and you are then logged in. Now Firebase is installed on our computer and we can begin deployment.

Preparing the Vue.js app for deployment

Now that we are done with the development of the app, it’s time to deploy the application to production via Firebase Hosting. How exactly do we do that? First of all, we need to build the Vue.js app for production and then run the Firebase deploy command. Let’s get started on that by running the command below.

npm run build

The command above helps to minify JS, HTML, and CSS. All static assets are also compiled with version hashes for efficient long-term caching, and a production index.html is auto-generated with proper URLs to these generated assets.

Once the command is done with its process, the production-ready app can be found in the dist folder. That is where the firebase deploy command will be used.

Deploying to Firebase

We’ll need to initiate Firebase for this project, specifically inside the dist folder. So run the command firebase init. That command prompts the following:

  1. You’ll be prompted to choose which of the Firebase CLI feature you want to use, choose Hosting.
  2. You’ll be prompted to associate the current project directory with a Firebase project. Choose a Firebase project or create a new one.
  3. You’ll be prompted to type in the name of the folder you want to use as a public directory. This public directory is the folder (relative to your project directory) that will contain Hosting assets to be uploaded with firebase deploy. In this case, the name of the folder is dist.
  4. You’ll be prompted to choose whether to configure the project as a single-page app. Choose Y.

The initialization process should be completed and we can run the firebase deploy command now. When the deploy process is done, a live URL will be generated automatically by Firebase, in this case, hamilton-lyrics.firebaseapp.com. Firebase allows you to connect a domain to your Hosting instance so you can use a custom domain for your applications.

Conclusion

In this tutorial, we learnt how to deploy static pages to Firebase Hosting and making it realtime by using Pusher. We saw how to implement Pusher’s Presence Channels by using it to identify two different users online and then using it to build a multiplayer game. We also learnt how Client Events work, how to trigger events from the client side as opposed to triggering from a backend server.

Firebase offers a slew of services that can help you to build apps faster and you can read about them on the Firebase site.

If you want to go through the source code for the game above, you can do that on Github. You can also see the live demo at hamilton-lyrics.firebaseapp.com.

This post first appeared on the Pusher blog.

Discover and read more posts from Yomi Eluwande
get started
post commentsBe the first to share your opinion
Show more replies