🎉 New release for Pusher Chatkit - Webhooks! Extend your in-app chat functionality
Hide
Products
chatkit_full-logo

Extensible API for in-app chat

channels_full-logo

Build scalable realtime features

beams_full-logo

Programmatic push notifications

Developers

Docs

Read the docs to learn how to use our products

Tutorials

Explore our tutorials to build apps with Pusher products

Support

Reach out to our support team for help and advice

Sign in
Sign up

Creating a live multiplayer quiz app in React Native

  • Wern Ancheta
March 28th, 2019
You will need Node 11+, Yarn and React Native installed on your machine.

In this tutorial, we’ll create a quiz app which can cater to multiple users in realtime.

Prerequisites

Knowledge of Node.js and React Native is required to follow this tutorial. This also means your machine needs to have the React Native development environment.

We’ll be using Yarn to install dependencies.

You’ll also need a Pusher app instance and an ngrok account. Enable client events on your Pusher app so we can trigger events from the app itself.

The following package versions are used in this tutorial:

  • Node 11.2.0
  • Yarn 1.13.0
  • React Native 0.58.5

App overview

We will create a multi-player quiz app. Users will be given 10 multiple choice questions and they have to select the correct answer to each one as they are displayed on the screen.

When the user opens the app, they have to log in. This serves as their identification in the game:

Once they’re logged in, a loading animation will be displayed while waiting for the admin to trigger the questions.

The game starts when the first question is displayed on the screen. As soon as the user picks an option, either correct or wrong mark will be displayed next to the option they selected. Once the user selects an option, they can no longer select another one. Users have 10 seconds to answer each question. If they answer after the countdown (displayed in the upper right corner), their answer won’t be considered.

After all 10 questions have been displayed, the top users are displayed and that ends the game:

Setting up

Clone the repo and switch to the starter branch to save time in setting up the app and adding boilerplate code:

    git clone https://github.com/anchetaWern/RNQuiz
    cd RNQuiz
    git checkout starter

Next, install the dependencies and link them up:

    yarn
    react-native eject
    react-native link react-native-config
    react-native link react-native-gesture-handler
    react-native link react-native-vector-icons

The starter branch already has the two pages set up. All the styles that the app will use are also included. So all we have to do is add the structure and logic.

Next, update your android/app/src/main/AndroidManifest.xml and add the permission for accessing the network state. This is required by Pusher:

    <manifest xmlns:android="http://schemas.android.com/apk/res/android"
      package="com.rnquiz">
        <uses-permission android:name="android.permission.INTERNET" />
        <uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW"/>
        <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/> <!-- add this -->
    </manifest>

Next, update android/app/build.gradle to include the .gradle file for the React Native Config package. We’ll be using it to use .env configuration files inside the project:

    apply from: "../../node_modules/react-native/react.gradle"
    apply from: project(':react-native-config').projectDir.getPath() + "/dotenv.gradle"

Next, create a .env file at the root of the React Native project and add your Pusher app credentials:

    PUSHER_APP_KEY="YOUR PUSHER APP KEY"
    PUSHER_APP_CLUSTER="YOUR PUSHER APP CLUSTER"

Once you’re done with setting up the app, do the same for the server as well:

    cd server
    yarn

The server doesn’t have boilerplate code already set up so we’ll write everything from scratch.

Lastly, create a server/.env file and add your Pusher app credentials:

    APP_ID="YOUR PUSHER APP ID"
    APP_KEY="YOUR PUSHER APP KEY"
    APP_SECRET="YOUR PUSHER APP SECRET"
    APP_CLUSTER="YOUR PUSHER APP CLUSTER"

Quiz server

Before we add the code for the actual app, we have to create the server first. This is where we add the code for creating the database and displaying the UI for creating quiz items.

Navigate inside the server directory if you haven’t already. Inside, create an index.js file and add the following:

    const express = require("express"); // server framework
    const bodyParser = require("body-parser"); // for parsing the form data
    const Pusher = require("pusher"); // for sending realtime messages
    const cors = require("cors"); // for accepting requests from any host
    const mustacheExpress = require('mustache-express'); // for using Mustache for templating

    const { check } = require('express-validator/check'); // for validating user input for the quiz items

    const sqlite3 = require('sqlite3').verbose(); // database engine
    const db = new sqlite3.Database('db.sqlite'); // database file in the root of the server directory

Next, add the code for using the server packages we’ve imported above:

    const app = express();
    app.use(bodyParser.json());
    app.use(bodyParser.urlencoded({ extended: false }));
    app.use(cors());
    app.engine('mustache', mustacheExpress());
    app.set('view engine', 'mustache');
    app.set('views', __dirname + '/views'); // set the location of mustache files

Set up Pusher:

    const pusher = new Pusher({
      appId: process.env.APP_ID,
      key: process.env.APP_KEY,
      secret: process.env.APP_SECRET,
      cluster: process.env.APP_CLUSTER
    });

Next, add the code for authenticating users with Pusher and logging them into the server:

    var users = []; // this will store the username and scores for each user

    app.post("/pusher/auth", (req, res) => {
      const socketId = req.body.socket_id;
      const channel = req.body.channel_name;

      const auth = pusher.authenticate(socketId, channel);
      res.send(auth);
    });

    app.post("/login", (req, res) => {
      const username = req.body.username;
      console.log(username + " logged in");

      if (users.indexOf(username) === -1) { // check if user doesn't already exist
        console.log('users: ', users.length);
        users.push({
          username,
          score: 0 // initial score
        });
      }

      res.send('ok');
    });

Next, add the code for creating the database. Note that this step is optional as I have already added the db.sqlite file at the root of the server directory. That’s the database file which contains a few questions that I used for testing. If you want to start anew, simply create an empty db.sqlite file through the command line (or your text editor) and access the below route on your browser:

    app.get("/create-db", (req, res) => {
      db.serialize(() => {
        db.run('CREATE TABLE [quiz_items] ([question] VARCHAR(255), [optionA] VARCHAR(255), [optionB] VARCHAR(255), [optionC] VARCHAR(255), [optionD] VARCHAR(255), [answer] CHARACTER(1))');
      });

      db.close();
      res.send('ok');
    });

Next, add the route for displaying the UI for adding quiz items. This uses the Mustache Express library to render the quiz_creator template inside the views folder:

    app.get("/create-quiz", (req, res) => {
      res.render('quiz_creator');
    });

Here’s the code for the quiz creator template. Create a views/quiz_creator.mustache file and add the following:

    <!DOCTYPE html>
    <html lang="en">
    <head>
      <meta charset="UTF-8"/>
      <title>Quiz Creator</title>
      <link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/css/bootstrap.min.css" integrity="sha384-ggOyR0iXCbMQv3Xipma34MD+dH/1fQ784/j6cY/iJTQUOhcWr7x9JvoRxT2MZw1T" crossorigin="anonymous">
      <style>
      .hidden {
        display: none;
      }
      </style>
    </head>

    <body>

      <div class="container">
        <div class="row align-items-center">
          <div class="col col-lg-12">
            <h1>Create Quiz</h1>

            <div class="alert alert-success hidden">
            Item created!
            </div>

            <form method="POST" action="/save-item">
              <div class="form-group">
                <label for="question">Question</label>
                <input type="text" id="question" name="question" class="form-control" required>
              </div>

              <div class="form-group">
                <label for="option_a">Option A</label>
                <input type="text" id="option_a" name="option_a" class="form-control" required>
              </div>

              <div class="form-group">
                <label for="option_b">Option B</label>
                <input type="text" id="option_b" name="option_b" class="form-control" required>
              </div>

              <div class="form-group">
                <label for="option_c">Option C</label>
                <input type="text" id="option_c" name="option_c" class="form-control" required>
              </div>

              <div class="form-group">
                <label for="option_d">Option D</label>
                <input type="text" id="option_d" name="option_d" class="form-control" required>
              </div>

              Correct Answer

              <div class="form-group">
                <div class="form-check">
                  <input class="form-check-input" type="radio" name="answer" id="correct_a" value="A">
                  <label class="form-check-label" for="correct_a">
                    A
                  </label>
                </div>

                <div class="form-check">
                  <input class="form-check-input" type="radio" name="answer" id="correct_b" value="B">
                  <label class="form-check-label" for="correct_b">
                    B
                  </label>
                </div>

                <div class="form-check">
                  <input class="form-check-input" type="radio" name="answer" id="correct_c" value="C">
                  <label class="form-check-label" for="correct_c">
                    C
                  </label>
                </div>

                <div class="form-check">
                  <input class="form-check-input" type="radio" name="answer" id="correct_d" value="D">
                  <label class="form-check-label" for="correct_d">
                    D
                  </label>
                </div>
              </div>

              <button type="submit" class="btn btn-primary">Save Item</button>
            </form>
          </div>
        </div>
      </div>

      <script>
        if (window.location.hash) {
          document.querySelector('.alert').classList.remove('hidden');
        }
      </script>

    </body>
    </html>

Note that we haven’t really used the templating engine in the above template. But it’s a good practice to use it if you’re expecting to display dynamic data.

Next, add the route where the form data will be submitted. This has a simple validation where the length of each text field should not be less than one. Once the data is validated, we insert a new quiz item to the table:

    // server/index.js
    const required = { min: 1 }; // minimum number of characters required for each field

    app.post("/save-item", [
      check('question').isLength(required),
      check('option_a').isLength(required),
      check('option_b').isLength(required),
      check('option_c').isLength(required),
      check('option_d').isLength(required),
      check('answer').isLength(required) // the letter of the answer (e.g. A, B, C, D)
    ], (req, res) => {

      const { question, option_a, option_b, option_c, option_d, answer } = req.body;
      db.serialize(() => {
        var stmt = db.prepare('INSERT INTO quiz_items VALUES (?, ?, ?, ?, ?, ?)');
        stmt.run([question, option_a, option_b, option_c, option_d, answer]);
      });

      res.redirect('/create-quiz#ok'); // redirect back to the page for creating a quiz item
    });

Next, add the code for sending the questions. This selects ten random rows from the table and sends them at an interval (every 13 seconds). The users will only have ten seconds to answer each question, but we included an additional three seconds to cater for the latency (delay) in the network and in the app:

    const channel_name = 'quiz-channel';
    const question_timing = 13000; // 10 secs to show + 3 secs latency
    const question_count = 10;
    const top_users_delay = 10000; // additional delay between displaying the last question and the top users

    app.get("/questions", (req, res) => {
      var index = 1;
      db.each('SELECT question, answer, optionA, optionB, optionC, optionD, answer FROM quiz_items ORDER BY random() LIMIT ' + question_count, (err, row) => {
        timedQuestion(row, index);
        index += 1;
      });

      // next: add code for sending top users

      res.send('ok');
    });

    // next: add code for timedQuestion function

After all the questions have been sent, we send the top three users to all users who are currently subscribed to the quiz channel:

    setTimeout(() => {
      console.log('now triggering score...');
      const sorted_users_by_score = users.sort((a, b) => b.score - a.score)
      const top_3_users = sorted_users_by_score.slice(0, 1); // replace 1 with 3

      pusher.trigger(channel_name, 'top-users', {
        users: top_3_users
      });
    }, (question_timing * (question_count + 2)) + top_users_delay);

Here’s the code for the timedQuestion function we used earlier. All it does is send each individual row from the table:

    const timedQuestion = (row, index) => {
      setTimeout(() => {
        Object.assign(row, { index });

        pusher.trigger(
          channel_name,
          'question-given',
          row
        );

      }, index * question_timing);
    }

Next, add the route for incrementing user scores. This finds the user with the specified username in the array of users and then increments their score:

    app.post("/increment-score", (req, res) => {
      const { username } = req.body;
      console.log(`incremented score of ${username}`);

      const user_index = users.findIndex(user => user.username == username);
      users[user_index].score += 1;

      res.send('ok');
    });

Note that all users make a request to the above route every time they answer correctly so it’s a potential bottleneck. This is especially true if there are thousands of users using the app at the same time. If you’re planning to create a multi-player quiz app of your own, you might want to use Pusher on the server side to listen for messages sent by users. From there, you can increment their scores as usual.

Lastly, run the server on a specific port:

    var port = process.env.PORT || 5000;
    app.listen(port);

Quiz app

Now that we’ve added the server code, we’re ready to work on the actual app. As mentioned earlier, the boilerplate code has already been set up so all we have to do is add the UI structure and the logic.

Login screen

Open the login screen file and add the following:

    // src/screens/Login.js
    import React, { Component } from "react";
    import { View, Text, TextInput, TouchableOpacity, Alert } from "react-native";

    import Pusher from "pusher-js/react-native"; // for using Pusher
    import Config from "react-native-config"; // for using .env config file

    import axios from 'axios'; // for making http requests

    const pusher_app_key = Config.PUSHER_APP_KEY;
    const pusher_app_cluster = Config.PUSHER_APP_CLUSTER;
    const base_url = "YOUR NGROK HTTPS URL";

    class LoginScreen extends Component {
      static navigationOptions = {
        header: null
      };

      state = {
        username: "",
        enteredQuiz: false
      };

      constructor(props) {
        super(props);
        this.pusher = null;
      }

      // next: add render()
    }

    export default LoginScreen;

Next, render the login UI:

    render() {
      return (
        <View style={styles.wrapper}>
          <View style={styles.container}>
            <View style={styles.main}>
              <View>
                <Text style={styles.label}>Enter your username</Text>
                <TextInput
                  style={styles.textInput}
                  onChangeText={username => this.setState({ username })}
                  value={this.state.username}
                />
              </View>

              {!this.state.enteredQuiz && (
                <TouchableOpacity onPress={this.enterQuiz}>
                  <View style={styles.button}>
                    <Text style={styles.buttonText}>Login</Text>
                  </View>
                </TouchableOpacity>
              )}

              {this.state.enteredQuiz && (
                <Text style={styles.loadingText}>Loading...</Text>
              )}
            </View>
          </View>
        </View>
      );
    }

When the user clicks on the login button, we authenticate them via Pusher and log them into the server. As you’ve seen in the server code earlier, this allows us to add the user to the users array which is then used later to filter for the top users:

    enterQuiz = async () => {
      const myUsername = this.state.username;

      if (myUsername) {
        this.setState({
          enteredQuiz: true // show loading animation
        });

        this.pusher = new Pusher(pusher_app_key, {
          authEndpoint: `${base_url}/pusher/auth`,
          cluster: pusher_app_cluster,
          encrypted: true
        });

        try {
          await axios.post(
            `${base_url}/login`, 
            {
              username: myUsername
            }
          );
          console.log('logged in!');
        } catch (err) {
          console.log(`error logging in ${err}`);
        }

        // next: add code for subscribing to quiz channel

      }
    };

Next, listen for Pusher’s channel subscription success event and navigate the user to the Quiz screen. We pass the Pusher reference, username and quiz channel as navigation params so we can also use it in the Quiz screen:

    this.quizChannel = this.pusher.subscribe('quiz-channel');
    this.quizChannel.bind("pusher:subscription_error", (status) => {
      Alert.alert(
        "Error",
        "Subscription error occurred. Please restart the app"
      );
    });

    this.quizChannel.bind("pusher:subscription_succeeded", () => {     
      this.props.navigation.navigate("Quiz", {
        pusher: this.pusher,
        myUsername: myUsername,
        quizChannel: this.quizChannel
      });

      this.setState({
        username: "",
        enteredQuiz: false // hide loading animation
      });
    });

Quiz screen

The Quiz screen is the main meat of the app. This is where the questions are displayed for the user to answer. Start by importing all the packages we need:

    // src/screens/Quiz.js
    import React, { Component } from "react";
    import { View, Text, ActivityIndicator, TouchableOpacity } from "react-native";
    import axios from 'axios';
    import Icon from 'react-native-vector-icons/FontAwesome';

    const option_letters = ['A', 'B', 'C', 'D'];
    const base_url = "YOUR NGROK HTTPS URL";

Next, initialize the state:

    class Quiz extends Component {

      static navigationOptions = {
        header: null
      };

      state = {
        display_question: false, // whether to display the questions or not
        countdown: 10, // seconds countdown for answering the question
        show_result: false, // whether to show whether the user's answer is correct or incorrect
        selected_option: null, // the last option (A, B, C, D) selected by the user
        disable_options: true, // whether to disable the options from being interacted on or not
        total_score: 0, // the user's total score

        index: 1, // the index of the question being displayed
        display_top_users: false // whether to display the top users or not
      }

      // next: add constructor
    }

    export default Quiz;

Inside the constructor, we get the navigation params that were passed from the login screen earlier. Then we listen for the question-given event to be triggered by the server. As you’ve seen earlier, this contains the question data (question, four options, and answer). We just set those into the state so they’re displayed. After that, we immediately start the countdown so that the number displayed on the upper right corner counts down every second:

    constructor(props) {
      super(props);
      const { navigation } = this.props;

      this.pusher = navigation.getParam('pusher');
      this.myUsername = navigation.getParam('myUsername');
      this.quizChannel = navigation.getParam('quizChannel');

      this.quizChannel.bind('question-given', ({ index, question, optionA, optionB, optionC, optionD, answer }) => {

        this.setState({
          display_question: true, // display the question in the UI
          countdown: 10, // start countdown
          selected_option: null,
          show_result: false, 
          disable_options: false,

          // question to display
          index, 
          question, 
          optionA, 
          optionB, 
          optionC, 
          optionD, 
          answer
        });

        // start the countdown
        const interval = setInterval(() => {
          this.setState((prevState) => {
            const cnt = (prevState.countdown > 0) ? prevState.countdown - 1 : 0
            if (cnt == 0) {
              clearInterval(interval);
            }

            return {
              countdown: cnt
            }
          });
        }, 1000);

      });

      // next: add listener for top users
    }

Next, listen for the top-users event. This will display the names and scores of the top users:

    this.quizChannel.bind('top-users', ({ users }) => {
      console.log('received top users: ', JSON.stringify(users));
      this.setState({
        top_users: users,
        display_top_users: true
      });
    });

Next, render the UI. When the user is first redirected from the login screen, only the total score, default countdown value, and the activity indicator are displayed. When the server starts sending questions, the activity indicator is hidden in place of the question and its options. Lastly, when the server sends the top users, the question and its options are hidden in place of the list of top users:

    render() {
      const { 
        total_score,
        countdown, 
        index, 
        question, 
        optionA, 
        optionB, 
        optionC, 
        optionD, 
        answer, 

        display_question,
        top_users,
        display_top_users
      } = this.state;

      const options = [optionA, optionB, optionC, optionD];

      return (
        <View style={styles.container}>

          <View>
            <Text>Total Score: {total_score}</Text>
          </View>

          <View style={styles.countdown}>
            <Text style={styles.countdown_text}>{countdown}</Text>
          </View>

          {
            !display_question &&
            <View style={styles.centered}>
              <ActivityIndicator size="large" color="#0000ff" />
            </View>
          }

          {
            display_question && !display_top_users && 
            <View style={styles.quiz}>
              {
                !showAnswer &&
                <View>
                  <View>
                    <Text style={styles.big_text}>{question}</Text>
                  </View>

                  <View style={styles.list_container}>
                    { this.renderOptions(options, answer) }
                  </View>
                </View>
              }
            </View>
          }

          {
            display_top_users &&
            <View style={styles.top_users}>
              <Text style={styles.big_text}>Top Users</Text>
              <View style={styles.list_container}>
              { this.renderTopUsers() }
              </View>
            </View>
          }

        </View>
      );
    }

Here’s the code for rendering the options. Each one executes the placeAnswer function when the user clicks on it. As soon as an option is selected, the icon which represents whether they’re correct or not is immediately displayed next to it:

    renderOptions = (options, answer) => {
      const { show_result, selected_option, disable_options } = this.state;

      return options.map((opt, index) => {
        const letter = option_letters[index];
        const is_selected = selected_option == letter;
        const is_answer = (letter == answer) ? true : false; 

        return (
          <TouchableOpacity disabled={disable_options} onPress={() => this.placeAnswer(index, answer)} key={index}>
            <View style={styles.option}>
              <Text style={styles.option_text}>{opt}</Text>

              {
                is_answer && show_result && is_selected && <Icon name="check" size={25} color="#28a745" />
              }

              {
                !is_answer && show_result && is_selected && <Icon name="times" size={25} color="#d73a49" />
              }
            </View>
          </TouchableOpacity>
        );
      });
    }

Here’s the placeAnswer function. This accepts the index of the selected option (0, 1, 2, or 3) and the letter of the answer. Those are used to determine if the user answered correctly or not. The answer isn’t even considered if the user missed the countdown. If they answered correctly, their total score is incremented by one and the app makes a request to the server to increment the user’s score:

    placeAnswer = (index, answer) => {

      const selected_option = option_letters[index]; // the letter of the selected option
      const { countdown, total_score } = this.state;

      if (countdown > 0) { // 
        if (selected_option == answer) { 
          this.setState((prevState) => {
            return {
              total_score: prevState.total_score + 1
            }
          });

          axios.post(base_url + '/increment-score', {
            username: this.myUsername
          }).then(() => {
            console.log('incremented score');
          }).catch((err) => {
            console.log('error occurred: ', e);
          });
        } 
      }

      this.setState({
        show_result: true, // show whether the user answered correctly or not
        disable_options: true, // disallow the user from selecting any of the options again
        selected_option // the selected option (letter)
      });
    }

Here’s the code for rendering the top users:

    renderTopUsers = () => {
      const { top_users } = this.state;
      return top_users.map(({ username, score }) => {
        return (
          <View key={username}>
            <Text style={styles.sub_text}>{username}: {score} out of 10</Text>
          </View>
        );
      });
    }

Running the app

To run the app, you have to run the server first and expose it to the internet by using ngrok:

    cd server
    yarn start
    ~/.ngrok http 5000

If you haven’t used the db.sqlite file I provided in the repo, you have to access http://localhost:5000/create-db to create the database (Note: you first have to create an empty db.sqlite at the root of the server directory). After that, access http://localhost:5000/create-quiz and add some quiz items. Add at least 10 items.

Next, update your src/screens/Login.js and src/screens/Quiz.js file with your ngrok HTTPS URL and run the app:

    react-native run-android
    react-native run-ios

Lastly, access http://localhost:5000/questions to start sending the quiz items.

Conclusion

In this tutorial, we’ve created a multi-player quiz app using Node.js and React Native. Along the way, you learned how to use mustache templates and SQLite database within an Express app. Lastly, you learned how to use Node.js, React Native, and Pusher to easily implement a multi-player quiz app.

You can view the code on this GitHub repo.

Clone the project repository
  • Android
  • Gaming
  • iOS
  • JavaScript
  • Node.js
  • React
  • React Native
  • Channels

Products

  • Channels
  • Chatkit
  • Beams

© 2019 Pusher Ltd. All rights reserved.

Pusher Limited is a company registered in England and Wales (No. 07489873) whose registered office is at 160 Old Street, London, EC1V 9BW.