Skip to content

Instantly share code, notes, and snippets.

@supertinou
Last active September 8, 2015 11:08
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save supertinou/f56c6542cbb085e17739 to your computer and use it in GitHub Desktop.
Save supertinou/f56c6542cbb085e17739 to your computer and use it in GitHub Desktop.

Building a Realtime Quiz app with Ruby On Rails, ReactJS and PubNub

LiveQuiz Demo

In this tutorial we will learn how to build a realtime quiz app with Ruby On Rails and ReactJS using PubNub.

Project Overview

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 :

Technologies

  • 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.

Bootstrap application

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
LiveQuiz LiveQuiz

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

Goals and features

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 :

channels

Step 1 PubNub Account and API keys

First of all:

  • Create a PubNub account here and create an app.
  • Activate Access Manager and Presence feature in your dashboard.

Step 2 Setup of PubNub in your application

2.1 Setting up the dependencies

→ 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')

2.2 Creating a PubNub Singleton

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')

Step 3 Give to people a uniq quiz session access

LiveQuiz

In this step, we will see how to create an uniq quiz session access for the participants that you can share with them.

3.1 Create the route to access the session

Each participant will be able to access the sessions through a dedicated link:

LiveQuiz

→ 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.

3.2 Seting up the backbone of our 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()   

Passing configuration data from the controller to the js file

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

3.3 Restricting and granting access to the different channels

channels

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

Step 4 Building the participant list with online/offline status

App

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.

4.1 The ParticipantList component

Below is how the Participant List looks like:

Component

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: [] ) %>

4.2 Integrating with PubNub

4.2.1 Display the online 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:

4.2.2 Subscribe to the chat channel and update the status & infos of the 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()

4.2.3 Prerender the list of all the participants (optional)

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

Step 5 Send and receive the questions.

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.

5.1 Start the quiz and send the questions from the Rails app:

img

→ 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

5.2 Display and answer the question:

Component

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') %>

5.3 Receive the answers of the participants in the Rails app:

img

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

Step 6 Displaying the activity feed

feed

6.1 The Activity feed component:

→ 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' ) %>

6.2 Adding the login / logout events

→ In quiz.js.coffee.erb Just add the following in the presence callback, and that's all.

React.render(<ActivityFeed newActivity=message />, @react('ActivityFeed'))

6.3 Adding the answering events

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!

6.4 Display a success notification:

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'))

Step 7 Display the results

The last final feature of our tutorial will be to display the results at the end of the quiz.

results

→ 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'))

Credits

@supertinou
Copy link
Author

Diagram url: http://yuml.me/diagram/scruffy/class/[QUIZ|title;owner_email;access_key{bg:cornsilk}]1-N[QUESTION|title{bg:cornsilk}], [QUESTION]1-N[ANSWER|title;correct{bg:cornsilk}],[QUIZ]1-N[SESSION|access_key], [SESSION{bg:cornsilk}]1-N[PARTICIPANT|email;name;authorization_key;authorization_password{bg:cornsilk}]

@girliemac
Copy link

This is really well-written! Great job 👍

@supertinou
Copy link
Author

Thank you @girliemac :-)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment