Creating a live multiplayer quiz app in React Native

Introduction

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 account. Create a free sandbox Pusher account or sign in. Then go to the dashboard and create a Channels instance. Then you'll need a ngrok account. Enable client events on your Pusher Channels 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:

react-native-quiz-1

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.

react-native-quiz-2

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

react-native-quiz-3

Setting up

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

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

Next, install the dependencies and link them up:

1yarn
2    react-native eject
3    react-native link react-native-config
4    react-native link react-native-gesture-handler
5    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:

1<manifest xmlns:android="http://schemas.android.com/apk/res/android"
2      package="com.rnquiz">
3        <uses-permission android:name="android.permission.INTERNET" />
4        <uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW"/>
5        <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/> <!-- add this -->
6    </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:

1apply from: "../../node_modules/react-native/react.gradle"
2    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:

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

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

1cd server
2    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:

1APP_ID="YOUR PUSHER APP ID"
2    APP_KEY="YOUR PUSHER APP KEY"
3    APP_SECRET="YOUR PUSHER APP SECRET"
4    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:

1const express = require("express"); // server framework
2    const bodyParser = require("body-parser"); // for parsing the form data
3    const Pusher = require("pusher"); // for sending realtime messages
4    const cors = require("cors"); // for accepting requests from any host
5    const mustacheExpress = require('mustache-express'); // for using Mustache for templating
6    
7    const { check } = require('express-validator/check'); // for validating user input for the quiz items
8    
9    const sqlite3 = require('sqlite3').verbose(); // database engine
10    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:

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

Set up Pusher:

1const pusher = new Pusher({
2      appId: process.env.APP_ID,
3      key: process.env.APP_KEY,
4      secret: process.env.APP_SECRET,
5      cluster: process.env.APP_CLUSTER
6    });

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

1var users = []; // this will store the username and scores for each user
2    
3    app.post("/pusher/auth", (req, res) => {
4      const socketId = req.body.socket_id;
5      const channel = req.body.channel_name;
6    
7      const auth = pusher.authenticate(socketId, channel);
8      res.send(auth);
9    });
10    
11    app.post("/login", (req, res) => {
12      const username = req.body.username;
13      console.log(username + " logged in");
14    
15      if (users.indexOf(username) === -1) { // check if user doesn't already exist
16        console.log('users: ', users.length);
17        users.push({
18          username,
19          score: 0 // initial score
20        });
21      }
22    
23      res.send('ok');
24    });

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:

1app.get("/create-db", (req, res) => {
2      db.serialize(() => {
3        db.run('CREATE TABLE [quiz_items] ([question] VARCHAR(255), [optionA] VARCHAR(255), [optionB] VARCHAR(255), [optionC] VARCHAR(255), [optionD] VARCHAR(255), [answer] CHARACTER(1))');
4      });
5    
6      db.close();
7      res.send('ok');
8    });

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:

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

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

1<!DOCTYPE html>
2    <html lang="en">
3    <head>
4      <meta charset="UTF-8"/>
5      <title>Quiz Creator</title>
6      <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">
7      <style>
8      .hidden {
9        display: none;
10      }
11      </style>
12    </head>
13    
14    <body>
15    
16      <div class="container">
17        <div class="row align-items-center">
18          <div class="col col-lg-12">
19            <h1>Create Quiz</h1>
20    
21            <div class="alert alert-success hidden">
22            Item created!
23            </div>
24    
25            <form method="POST" action="/save-item">
26              <div class="form-group">
27                <label for="question">Question</label>
28                <input type="text" id="question" name="question" class="form-control" required>
29              </div>
30    
31              <div class="form-group">
32                <label for="option_a">Option A</label>
33                <input type="text" id="option_a" name="option_a" class="form-control" required>
34              </div>
35    
36              <div class="form-group">
37                <label for="option_b">Option B</label>
38                <input type="text" id="option_b" name="option_b" class="form-control" required>
39              </div>
40    
41              <div class="form-group">
42                <label for="option_c">Option C</label>
43                <input type="text" id="option_c" name="option_c" class="form-control" required>
44              </div>
45    
46              <div class="form-group">
47                <label for="option_d">Option D</label>
48                <input type="text" id="option_d" name="option_d" class="form-control" required>
49              </div>
50    
51              Correct Answer
52    
53              <div class="form-group">
54                <div class="form-check">
55                  <input class="form-check-input" type="radio" name="answer" id="correct_a" value="A">
56                  <label class="form-check-label" for="correct_a">
57                    A
58                  </label>
59                </div>
60    
61                <div class="form-check">
62                  <input class="form-check-input" type="radio" name="answer" id="correct_b" value="B">
63                  <label class="form-check-label" for="correct_b">
64                    B
65                  </label>
66                </div>
67    
68                <div class="form-check">
69                  <input class="form-check-input" type="radio" name="answer" id="correct_c" value="C">
70                  <label class="form-check-label" for="correct_c">
71                    C
72                  </label>
73                </div>
74    
75                <div class="form-check">
76                  <input class="form-check-input" type="radio" name="answer" id="correct_d" value="D">
77                  <label class="form-check-label" for="correct_d">
78                    D
79                  </label>
80                </div>
81              </div>
82    
83              <button type="submit" class="btn btn-primary">Save Item</button>
84            </form>
85          </div>
86        </div>
87      </div>
88    
89      <script>
90        if (window.location.hash) {
91          document.querySelector('.alert').classList.remove('hidden');
92        }
93      </script>
94    
95    </body>
96    </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:

1// server/index.js
2    const required = { min: 1 }; // minimum number of characters required for each field
3    
4    app.post("/save-item", [
5      check('question').isLength(required),
6      check('option_a').isLength(required),
7      check('option_b').isLength(required),
8      check('option_c').isLength(required),
9      check('option_d').isLength(required),
10      check('answer').isLength(required) // the letter of the answer (e.g. A, B, C, D)
11    ], (req, res) => {
12    
13      const { question, option_a, option_b, option_c, option_d, answer } = req.body;
14      db.serialize(() => {
15        var stmt = db.prepare('INSERT INTO quiz_items VALUES (?, ?, ?, ?, ?, ?)');
16        stmt.run([question, option_a, option_b, option_c, option_d, answer]);
17      });
18    
19      res.redirect('/create-quiz#ok'); // redirect back to the page for creating a quiz item
20    });

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:

1const channel_name = 'quiz-channel';
2    const question_timing = 13000; // 10 secs to show + 3 secs latency
3    const question_count = 10;
4    const top_users_delay = 10000; // additional delay between displaying the last question and the top users
5    
6    app.get("/questions", (req, res) => {
7      var index = 1;
8      db.each('SELECT question, answer, optionA, optionB, optionC, optionD, answer FROM quiz_items ORDER BY random() LIMIT ' + question_count, (err, row) => {
9        timedQuestion(row, index);
10        index += 1;
11      });
12    
13      // next: add code for sending top users
14    
15      res.send('ok');
16    });
17    
18    // 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:

1setTimeout(() => {
2      console.log('now triggering score...');
3      const sorted_users_by_score = users.sort((a, b) => b.score - a.score)
4      const top_3_users = sorted_users_by_score.slice(0, 1); // replace 1 with 3
5    
6      pusher.trigger(channel_name, 'top-users', {
7        users: top_3_users
8      });
9    }, (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:

1const timedQuestion = (row, index) => {
2      setTimeout(() => {
3        Object.assign(row, { index });
4    
5        pusher.trigger(
6          channel_name,
7          'question-given',
8          row
9        );
10    
11      }, index * question_timing);
12    }

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:

1app.post("/increment-score", (req, res) => {
2      const { username } = req.body;
3      console.log(`incremented score of ${username}`);
4    
5      const user_index = users.findIndex(user => user.username == username);
6      users[user_index].score += 1;
7    
8      res.send('ok');
9    });

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:

1var port = process.env.PORT || 5000;
2    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:

1// src/screens/Login.js
2    import React, { Component } from "react";
3    import { View, Text, TextInput, TouchableOpacity, Alert } from "react-native";
4    
5    import Pusher from "pusher-js/react-native"; // for using Pusher
6    import Config from "react-native-config"; // for using .env config file
7    
8    import axios from 'axios'; // for making http requests
9    
10    const pusher_app_key = Config.PUSHER_APP_KEY;
11    const pusher_app_cluster = Config.PUSHER_APP_CLUSTER;
12    const base_url = "YOUR NGROK HTTPS URL";
13    
14    class LoginScreen extends Component {
15      static navigationOptions = {
16        header: null
17      };
18    
19      state = {
20        username: "",
21        enteredQuiz: false
22      };
23    
24      constructor(props) {
25        super(props);
26        this.pusher = null;
27      }
28    
29      // next: add render()
30    }
31    
32    export default LoginScreen;

Next, render the login UI:

1render() {
2      return (
3        <View style={styles.wrapper}>
4          <View style={styles.container}>
5            <View style={styles.main}>
6              <View>
7                <Text style={styles.label}>Enter your username</Text>
8                <TextInput
9                  style={styles.textInput}
10                  onChangeText={username => this.setState({ username })}
11                  value={this.state.username}
12                />
13              </View>
14    
15              {!this.state.enteredQuiz && (
16                <TouchableOpacity onPress={this.enterQuiz}>
17                  <View style={styles.button}>
18                    <Text style={styles.buttonText}>Login</Text>
19                  </View>
20                </TouchableOpacity>
21              )}
22    
23              {this.state.enteredQuiz && (
24                <Text style={styles.loadingText}>Loading...</Text>
25              )}
26            </View>
27          </View>
28        </View>
29      );
30    }

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:

1enterQuiz = async () => {
2      const myUsername = this.state.username;
3    
4      if (myUsername) {
5        this.setState({
6          enteredQuiz: true // show loading animation
7        });
8    
9        this.pusher = new Pusher(pusher_app_key, {
10          authEndpoint: `${base_url}/pusher/auth`,
11          cluster: pusher_app_cluster,
12          encrypted: true
13        });
14        
15        try {
16          await axios.post(
17            `${base_url}/login`, 
18            {
19              username: myUsername
20            }
21          );
22          console.log('logged in!');
23        } catch (err) {
24          console.log(`error logging in ${err}`);
25        }
26        
27        // next: add code for subscribing to quiz channel
28    
29      }
30    };

Next, listen for Pusher Channels 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:

1this.quizChannel = this.pusher.subscribe('quiz-channel');
2    this.quizChannel.bind("pusher:subscription_error", (status) => {
3      Alert.alert(
4        "Error",
5        "Subscription error occurred. Please restart the app"
6      );
7    });
8    
9    this.quizChannel.bind("pusher:subscription_succeeded", () => {     
10      this.props.navigation.navigate("Quiz", {
11        pusher: this.pusher,
12        myUsername: myUsername,
13        quizChannel: this.quizChannel
14      });
15    
16      this.setState({
17        username: "",
18        enteredQuiz: false // hide loading animation
19      });
20    });

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:

1// src/screens/Quiz.js
2    import React, { Component } from "react";
3    import { View, Text, ActivityIndicator, TouchableOpacity } from "react-native";
4    import axios from 'axios';
5    import Icon from 'react-native-vector-icons/FontAwesome';
6    
7    const option_letters = ['A', 'B', 'C', 'D'];
8    const base_url = "YOUR NGROK HTTPS URL";

Next, initialize the state:

1class Quiz extends Component {
2      
3      static navigationOptions = {
4        header: null
5      };
6    
7      state = {
8        display_question: false, // whether to display the questions or not
9        countdown: 10, // seconds countdown for answering the question
10        show_result: false, // whether to show whether the user's answer is correct or incorrect
11        selected_option: null, // the last option (A, B, C, D) selected by the user
12        disable_options: true, // whether to disable the options from being interacted on or not
13        total_score: 0, // the user's total score
14       
15        index: 1, // the index of the question being displayed
16        display_top_users: false // whether to display the top users or not
17      }
18    
19      // next: add constructor
20    }
21    
22    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:

1constructor(props) {
2      super(props);
3      const { navigation } = this.props;
4      
5      this.pusher = navigation.getParam('pusher');
6      this.myUsername = navigation.getParam('myUsername');
7      this.quizChannel = navigation.getParam('quizChannel');
8    
9      this.quizChannel.bind('question-given', ({ index, question, optionA, optionB, optionC, optionD, answer }) => {
10        
11        this.setState({
12          display_question: true, // display the question in the UI
13          countdown: 10, // start countdown
14          selected_option: null,
15          show_result: false, 
16          disable_options: false,
17          
18          // question to display
19          index, 
20          question, 
21          optionA, 
22          optionB, 
23          optionC, 
24          optionD, 
25          answer
26        });
27        
28        // start the countdown
29        const interval = setInterval(() => {
30          this.setState((prevState) => {
31            const cnt = (prevState.countdown > 0) ? prevState.countdown - 1 : 0
32            if (cnt == 0) {
33              clearInterval(interval);
34            }
35    
36            return {
37              countdown: cnt
38            }
39          });
40        }, 1000);
41    
42      });
43      
44      // next: add listener for top users
45    }

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

1this.quizChannel.bind('top-users', ({ users }) => {
2      console.log('received top users: ', JSON.stringify(users));
3      this.setState({
4        top_users: users,
5        display_top_users: true
6      });
7    });

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:

1render() {
2      const { 
3        total_score,
4        countdown, 
5        index, 
6        question, 
7        optionA, 
8        optionB, 
9        optionC, 
10        optionD, 
11        answer, 
12    
13        display_question,
14        top_users,
15        display_top_users
16      } = this.state;
17      
18      const options = [optionA, optionB, optionC, optionD];
19    
20      return (
21        <View style={styles.container}>
22          
23          <View>
24            <Text>Total Score: {total_score}</Text>
25          </View>
26    
27          <View style={styles.countdown}>
28            <Text style={styles.countdown_text}>{countdown}</Text>
29          </View>
30    
31          {
32            !display_question &&
33            <View style={styles.centered}>
34              <ActivityIndicator size="large" color="#0000ff" />
35            </View>
36          }
37    
38          {
39            display_question && !display_top_users && 
40            <View style={styles.quiz}>
41              {
42                !showAnswer &&
43                <View>
44                  <View>
45                    <Text style={styles.big_text}>{question}</Text>
46                  </View>
47    
48                  <View style={styles.list_container}>
49                    { this.renderOptions(options, answer) }
50                  </View>
51                </View>
52              }
53            </View>
54          }
55    
56          {
57            display_top_users &&
58            <View style={styles.top_users}>
59              <Text style={styles.big_text}>Top Users</Text>
60              <View style={styles.list_container}>
61              { this.renderTopUsers() }
62              </View>
63            </View>
64          }
65              
66        </View>
67      );
68    }

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:

1renderOptions = (options, answer) => {
2      const { show_result, selected_option, disable_options } = this.state;
3    
4      return options.map((opt, index) => {
5        const letter = option_letters[index];
6        const is_selected = selected_option == letter;
7        const is_answer = (letter == answer) ? true : false; 
8    
9        return (
10          <TouchableOpacity disabled={disable_options} onPress={() => this.placeAnswer(index, answer)} key={index}>
11            <View style={styles.option}>
12              <Text style={styles.option_text}>{opt}</Text>
13    
14              {
15                is_answer && show_result && is_selected && <Icon name="check" size={25} color="#28a745" />
16              }
17              
18              {
19                !is_answer && show_result && is_selected && <Icon name="times" size={25} color="#d73a49" />
20              }
21            </View>
22          </TouchableOpacity>
23        );
24      });
25    }

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:

1placeAnswer = (index, answer) => {
2      
3      const selected_option = option_letters[index]; // the letter of the selected option
4      const { countdown, total_score } = this.state;
5    
6      if (countdown > 0) { // 
7        if (selected_option == answer) { 
8          this.setState((prevState) => {
9            return {
10              total_score: prevState.total_score + 1
11            }
12          });
13      
14          axios.post(base_url + '/increment-score', {
15            username: this.myUsername
16          }).then(() => {
17            console.log('incremented score');
18          }).catch((err) => {
19            console.log('error occurred: ', e);
20          });
21        } 
22      }
23    
24      this.setState({
25        show_result: true, // show whether the user answered correctly or not
26        disable_options: true, // disallow the user from selecting any of the options again
27        selected_option // the selected option (letter)
28      });
29    }

Here’s the code for rendering the top users:

1renderTopUsers = () => {
2      const { top_users } = this.state;
3      return top_users.map(({ username, score }) => {
4        return (
5          <View key={username}>
6            <Text style={styles.sub_text}>{username}: {score} out of 10</Text>
7          </View>
8        );
9      });
10    }

Running the app

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

1cd server
2    yarn start
3    ~/.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:

1react-native run-android
2    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 Channels to easily implement a multi-player quiz app.

You can view the code on this GitHub repo.