Creating a live multiplayer quiz app in React Native
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.
28 March 2019
by Wern Ancheta