In this tutorial we will learn how to build a realtime quiz app with Ruby On Rails and ReactJS using PubNub.
Through this tutorial we will learn how to build an app that create quiz session, invite people, let the quiz owner start the session, let the participants all play the quiz in the same time and get the results at the end. As you can see, on the left side of the screen the participants list is displayed and their statuses are refreshed. On the right side of the screen an activity feed is displayed.
At the end you will have something that looks like the animated image above.
Check out the full open source repository here
To create the app, we will be using :
- Ruby on Rails — to allow to create the quizzes and the sessions, send the questions, collect the answers and display the results
- PubNub — To allow to send and receive the data in realtime.
- This app be will using the Javascript and Ruby PubNub APIs.
- It will use PubNub Access Manager and Presence features to secure quizzes access and to allow to see the participants statuses.
- ReactJS — to allow to easily display and refresh the realtime data received from the PubNub Network.
Before starting we will need to have a simple Rails app that allows to :
- Create quizzes with many questions and many answers
- Create quiz sessions with many participants
The features of the app we will need looks like below :
Demo Screencast | UML Diagram |
---|---|
To get this app done, you can either :
-
Try to build it by yourself from the demo and the diagram above.
-
Follow the step by step tutorial : link is comming
-
Get the app already done from the github repository :
Run the following command in your terminal:
git clone git@github.com:supertinou/livequiz.git && cd livequiz && git reset --hard TAG-SAMPLE-QUIZ-APP
In the next steps we are going to add a realtime dimension to the bootstrap application. Here are the features we will implement step by step :
- Each participant can get a secure link to play the quiz session.
- The participants can see who are online / offline
- The owner of the quiz can start the quiz and the questions are sent to the participants each 30 seconds.
- The participant can answer the questions and see who has answered wrong or right in realtime
- The participants can see the results.
To achieve to build these features in our application we will need several PubNub channels :
First of all:
- Create a PubNub account here and create an app.
- Activate Access Manager and Presence feature in your dashboard.
→ In the Gemfile
, add the Ruby and Javascript PubNub client:
gem 'pubnub', '~> 3.7'
gem 'pubnub-js', '~> 3.7'
and run bundle install
→ In application.js
add the following so it compiles to the asset pipeline:
//= require pubnub
The PubNub ruby gem allows to use the PubNub framework by initalizing the pubnub instance through this way :
require 'pubnub'
pubnub = Pubnub.new(
subscribe_key: 'demo',
publish_key: 'demo',
)
pubnub.publish(message: 'hello', channel: 'chan')
In order to prevent to create multiple instance of the PubNub client in our application and prevent to copy past the configuration keys everywhere in our application we can create a PubNub singleton:
→ In app/lib
create a file named pubnub_singleton.rb
and copy/past the following content :
require 'singleton'
class PubnubSingleton
include Singleton
attr_accessor :pubnub
def initialize()
@pubnub = Pubnub.new(
publish_key: ENV['PUBNUB_PUBLISH_KEY'],
subscribe_key: ENV['PUBNUB_SUBSCRIBE_KEY'],
secret_key: ENV['PUBNUB_SECRET_KEY'],
logger: Rails.logger
)
end
def self.client
self.instance.pubnub
end
end
Set the PubNub keys environement variables. In Rails 4.1+ put them in secrets.yml
or use a gem such as dotenv
--
Then we we can easily use PubNub in this way in our application :
require 'pubnub_singleton'
PubnubSingleton.client.publish(message: 'hello', channel: 'chan')
In this step, we will see how to create an uniq quiz session access for the participants that you can share with them.
Each participant will be able to access the sessions through a dedicated link:
→ In config/routes.rb
add a route for the participant to access the session:
get 'quiz_sessions/:session_key/:authorization_key/:authorization_password', to: "quiz_sessions#show"
→ Create a QuizSessions controller quiz_sessions_controller.rb
:
class QuizSessionsController < ApplicationController
def show
@participant = Participant.find_by(authorization_key: params[:authorization_key], authorization_password: params[:authorization_password] )
@session = Session.find_by(access_key: params[:session_key])
if @participant.nil? || (@session.id != @participant.session )
redirect_to root_path, notice: 'You are not authorized to access this session'
end
end
end
→ In application_helper.rb
add the following helper to allow to build the quiz session link from a participant object :
def quiz_session_link(participant)
path = "/quiz_sessions/#{participant.session.access_key}/#{participant.authorization_key}/#{participant.authorization_password}"
"http://"+ ENV['HOST']+path
end
Then, use it to display the link dedicated to a participant in the participant list (as in the above screenshot) :
<%= quiz_session_link(@participant) %>
→ Create a file named show.html.erb
in app/views/quiz_sessions/
That's all, the quiz owner can now share the quiz session links to the participant.
You can now access the participant quiz session link but it's blank!
Let's create the backbone of our main JavaScript that will be the hearth of our realtime quiz application.
→ Create a quiz.js.coffee.erb
file and past the following content:
class LiveQuiz
constructor: () ->
@heartbeat = 20
@uuid = .............
@auth_key = ........
@session_key = ............
@participant = ..............
@client_channel = @session_key+"-client"
@server_channel = @session_key+"-server"
@chat_channel = @session_key+"-chat"
@pubnub = PUBNUB(
publish_key: '<%= ENV.fetch('PUBNUB_PUBLISH_KEY') %>'
subscribe_key: '<%= ENV.fetch('PUBNUB_SUBSCRIBE_KEY') %>'
auth_key: @auth_key
uuid: @uuid
origin: 'pubsub.pubnub.com'
ssl: true
)
##################### UTILITIES ########################
# Fetch a react component from the react name
react: (react_name) ->
$("[data-react-class=#{react_name}]").get(0)
@liveQuiz = new LiveQuiz()
As you can see we miss some main config information in our quiz.js.coffee.erb
file (a lot of dots here) and we need to get them from the QuizSessions
controller.
As you may have noticed, in Rails it can be tricky to pass variables from controller to a JS files. That's why we are gonna use the gon gem to make it easier:
→ In your Gemfile
add:
gem 'gon'
and run bundle install
→ In application.html.erb
add:
<%= include_gon %>
→ In the show
method in quiz_sessions_contoller.rb
you can now set variables to be passed to JS files quiz.js.coffee.erb
file :
gon.push({
participant: @participant,
session_key: @session.access_key
})
and that we can easily get back in our quiz.js.coffee.erb
file like that:
→ Replace the missing values in the constructor
method of quiz.js.coffee.erb
:
@uuid = gon.participant.authorization_key
@auth_key = gon.participant.authorization_password
@session_key = gon.session_key
@participant = gon.participant
We will be using the Access Manager feature provided by PubNub to make the channels private and grant specific rights (read / write) to specific users.
-
When we create the session, we first need to forbid ascess to all the session channels to dissalow everyone to publish or subscribe messages.
-
Then we need to set to the session (through a reserved auth key) full rights to all the channel for the application in order to be able later to subscribe and publish messages:
→ In session.rb
add:
after_commit :set_forbidden_access_to_session_channels, on: [:create,:destroy]
after_commit :allow_full_rights_to_channels_to_quiz, on: [:create]
def server_channel
"#{access_key}-server"
end
def client_channel
"#{access_key}-client"
end
def chat_channel
"#{access_key}-chat"
end
def set_forbidden_access_to_session_channels
[server_channel,client_channel,chat_channel].each do |chan|
PubnubSingleton.client.grant(http_sync: true, channel: chan, read: false, write: false){|envelope|}
end
end
def allow_full_rights_to_channels_to_quiz
[server_channel,client_channel].each do |chan|
PubnubSingleton.client.grant(http_sync: true, channel: chan, presence: chan, auth_key: auth_key, read: true, write: true){|envelope| puts envelope.payload}
end
end
Note that in this case we use,the http_sync: true
statement to ensure that the grant actions callbacks will be executed in the proper order.
→ In participant.rb
add:
after_commit :grant_access_to_session_channels, on: :create
after_commit :revoke_access_to_session_channels, on: :destroy
def grant_access_to_session_channels
PubnubSingleton.client.grant(channel: self.session.server_channel, auth_key: self.authorization_password , read: true, write: false){|envelope|}
PubnubSingleton.client.grant(channel: self.session.client_channel, auth_key: self.authorization_password , read: false, write: true){|envelope|}
PubnubSingleton.client.grant(channel: self.session.chat_channel, presence: self.session.chat_channel, auth_key: self.authorization_password){|envelope|}
end
def revoke_access_to_session_channels
PubnubSingleton.client.revoke(channel: self.session.server_channel, auth_key: self.authorization_password){|envelope|}
PubnubSingleton.client.revoke(channel: self.session.client_channel, auth_key: self.authorization_password){|envelope|}
PubnubSingleton.client.revoke(channel: self.session.chat_channel, presence: self.session.chat_channel, auth_key: self.authorization_password){|envelope|}
end
As you may have noticed the application is composed of 3 different features. Let's focus on the Participant List feature in this step.
We will be using the Presence feature provided by PubNub to allow to see which participants are online in the channel.
Below is how the Participant List looks like:
You can get the source of the ParticipantList ReactJS Component in this Gist file
→ Include the sources in your application and setup React in you Rails app using the react-rails gem
→ In show.html.erb
add:
<%= react_component('ParticipantsList', participants: [] ) %>
PubNub offer a feature that allows you to request the list of people who are online on a specific channel, this feature is called here_now
→ In quiz.js.coffee.erb
just add the following method:
whoIsHereNow: ->
@pubnub.here_now
channel: @chat_channel
state: true
callback: (message) =>
participants = _.map(message.uuids, (participant) ->
{ uuid: participant.uuid, status: 'online', name: participant.state.name, email: participant.state.email }
React.render(<ParticipantsList participants=participants />, @react('ParticipantsList'))
and call it in the constructor:
@whoIsHereNow()
You can now see the list of online participants:
Here, when we are connecting the participant to the chat channel dedicated to the session we are passing some data (its name and its email) using the state method. We are also updating the status of the participant when it is changing.
→ In quiz.js.coffee.erb
add the following methods:
subscribeToChatChannel: ->
@pubnub.subscribe
channel: @chat_channel
state:
name: @participant.name
email: @participant.email
message: ->
presence: @presenceCallback
connect: ->
heartbeat: @heartbeat
presenceCallback: (message) ->
status = switch message.action
when 'leave' then 'offline'
when 'timeout' then 'offline'
when 'join' then 'online'
data = message.data || {}
participants = [{uuid: message.uuid, status: status, name: data.name, email: data.email }]
React.render(<ParticipantsList participants=participants />, @react('ParticipantsList'))
and call it in the constructor:
@subscribeToChatChannel()
Optionnaly you can render the list of all the participants even if they are not connected yet to the chat (A black icon will be displayed for their disponibility) :
You may want to pre-build you React component like this:
→ In show.html.erb
, add :
<%= react_component('ParticipantsList', participants: @participant_list ) %>
→ In the show
method of quiz_sessions_controller.rb
:
@participant_list = @session.participants.to_a.collect do |participant|
status = (@participant.id == participant.id) ? 'online' : ''
{ uuid: participant.authorization_key, status: status , name: participant.name, email: participant.email }
end
In this step, we will implement the feature that will allow the quiz owner to start the quiz and send the questions to each participants in the same time.
→ Add a start
route for the session
resource and a start
method in sessions_controller.rb
and call the @session.start!
method from it.
→ Add a current_question_index
field to the session. We will need it to know which question is currenting displaying.
rails g AddCurrentQuestionIndexFieldToSessions current_question_index:integer
rake db:migrate
→ Add the rufus-scheduler
gem in your Gemfile
. We will need it to schedule the send of the questions.
→ In session.rb
we will implement the start!
method, copy pas the following:
def start!
self.current_question_index = 0
send_current_question()
schedule_switch_to_next_question!(30)
save()
end
def schedule_switch_to_next_question!(secondes)
Rufus::Scheduler.singleton.in "#{secondes}s" do
ActiveRecord::Base.connection_pool.with_connection do
if switch_to_next_question!
schedule_switch_to_next_question!(secondes)
else
finish!
end
end
end
end
def send_current_question
send_event_with_data('question', {question: current_question.format(:title_with_answers)} )
end
def send_event_with_data(event, data)
message = {event: event, data: data}
PubnubSingleton.client.publish(message: message, channel: self.server_channel, auth_key: auth_key){|envelope|}
end
def switch_to_next_question!
next_question_index = self.current_question_index + 1
next_question_exist = self.quiz.questions[next_question_index]
succeeded_to_switch = if !next_question_exist.nil?
self.current_question_index = next_question_index
send_current_question()
save()
else
false
end
return succeeded_to_switch
end
def finish!
## The quiz is finished, do whatever you want!
self.current_question_index = nil
end
def current_question
current_question_index ? self.quiz.questions[current_question_index] : nil
end
→ In question.rb
add the format method to display the question with the answers:
def format(format_name)
format = if format_name == :title_with_answers
h = { title: title }
h[:answers] = answers.to_a.collect{|answer| {id: answer.id, title: answer.title }}
h
else
raise 'Unknown format'
end
format
end
Now that the questions are sent every 30 seconds trough the server channel we will need to display them in our javascript app and allow the participant to answer the question :
→ In quiz.js.coffee.rb'
add the following methods:
subscribeToServerChannel: ->
@pubnub.subscribe(
channel: @server_channel
message: @serverCallback
connect: ->
)
serverCallback: (message, env, ch, timer, magic_ch) =>
switch(message.event)
when 'question' then React.render(<QuestionDisplay question=message.data.question />, @react('QuestionDisplay'))
### Commands called from the QuestionDisplay React component ###
answerQuestion: (id) ->
@sendEvent('answer', {answer_id: id})
sendEvent: (event, data) ->
@pubnub.publish
channel: @client_channel
message: { event: event, auth_key: @auth_key, data: data}
callback : ->
and Include the React component which is displaying the questions from this gist : https://gist.github.com/supertinou/ada5ebaca12dc4f4228f
→ In show.html.erb
add :
<%= react_component('QuestionDisplay') %>
Now that the participant send their answers to the questions through the client channel, we will need to intercept them in the Rails app:
→ In session.rb
just add:
def subscribe_to_client_events
PubnubSingleton.client.subscribe(
channel: client_channel,
auth_key: auth_key,
callback: handle_client_events
)
end
def handle_client_events
lambda do |envelope|
m = envelope.message
case m['event']
when 'answer'
handle_question_answer(m['auth_key'], m['data']['answer_id'])
end
end
end
def handle_question_answer(auth_key, answer_id)
# Do whatever you want
# Maybe store the answer in the database ?
end
We will want to store the answers sent by the participants:
→ Add a ParticipantAnswer model in order to save the answers of the participants:
rails g model ParticipantAnswer participant:references answer:references
rake db:migrate
→ In session.rb
, update the handle_question_answer
method:
def handle_question_answer(auth_key, answer_id)
ActiveRecord::Base.connection_pool.with_connection
participant = Participant.find_by(authorization_password: auth_key)
answer = Answer.find(answer_id)
allowed_to_answer_question = ( answer.question.id == current_question.id )
if allowed_to_answer_question && !participant.have_already_answered_the_question?(answer.question)
participant.answer_question(current_question, answer)
end
end
end
→ In participant.rb
, add:
def have_already_answered_the_question?(question)
question.participant_answers.where(participant_id: self.id).count >= 1
end
def answer_question(question, answer)
participant_answers.build(answer: answer)
save()
answer.correct?
end
def number_of_correct_answers
participant_answers.joins(:answer).where({answers: {correct: true}}).count
end
→ Get the source of the ActivityFeed component here : https://gist.github.com/supertinou/6f249bfe272b102673f0 and include it in your application.
→ In show.html.erb
<%= react_component('ActivityFeed' ) %>
→ In quiz.js.coffee.erb
Just add the following in the presence callback, and that's all.
React.render(<ActivityFeed newActivity=message />, @react('ActivityFeed'))
To be able to display the people who have answered the question wrong or right in the activity feed we need to check it in the app and send a proper answer
event through the client
channel
→ In session.rb
, update the handle_question_answer
method to add this when a question is answered:
answered_correctly = participant.answer_question(current_question, answer)
send_event_with_data('answered', {
uuid: participant.authorization_key,
name: participant.name,
timestamp: Time.now.to_i,
answered_correctly
})
Displaying in the app:
In quiz.js.coffee
in the serverCallback
method, add the following to displaying answering event :
switch(message.event)
when 'answered'
activity = {
action: message.event
timestamp: message.data.timestamp
uuid: message.data.uuid
data:
name: message.data.name
correct: message.data.correct
}
React.render(<ActivityFeed newActivity=activity />, @react('ActivityFeed'))
Feel free to send more events!
Let's take the opportunity that we are receiving the answering events to display a success notification whether your answer is correct or not:
Get the source of the SuccessNotifier commponent here: https://gist.github.com/supertinou/9e6ed76626f926de7f2e
and update the serverCallback
method :
switch(message.event)
when 'answered'
if message.data.uuid == @uuid
React.render(<SuccessNotifier success=message.data.correct />, @react('QuestionDisplay'))
The last final feature of our tutorial will be to display the results at the end of the quiz.
→ Get the source of the ResultsDisplay component from here
→ In session.rb
implements the finish method which is already called once the last question is finishing displaying.
def finish!
send_results()
self.current_question_index = nil
save()
end
def results
participants.collect do |participant|
{
points: participant.number_of_correct_answers,
uuid: participant.authorization_key,
name: participant.name,
email: participant.email,
correct_answers_number: participant.number_of_correct_answers,
wrong_answers_number: participant.number_of_wrong_answers
}
end
end
→ In quiz.js.coffee
in the serverCallback
method, add:
switch(message.event)
when 'results' then React.render(<ResultsDisplay results=message.data.results />, @react('ResultsDisplay'))
- The icons of this tutorial are provided by https://icons8.com/
Thank you @girliemac :-)