In this tutorial, we’ll create a quiz app which can cater to multiple users in realtime.
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:
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:
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"
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);
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.
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 });
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 }
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.
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.