The school quiz that doubled as an onboarding funnel

Trivia features are easy to underestimate.

At AlumniFire, in 2015, the quiz wasn't there to keep people busy for thirty seconds. It was there to make a school network feel real before someone even joined it.

That mattered because AlumniFire wasn't a generic social site. Each school had its own subdomain, its own network, and its own identity. So a school-knowledge quiz did two jobs at once:

  • it gave people something school-specific to engage with
  • it pulled them toward signup without feeling like a signup wall

Pretty good shape for a small feature.

The code around it has some very 2015 Rails habits. That's part of the context here. I'm not trying to pretend the implementation is brand new. I'm interested in the quiz logic and the product loop it supported.

The quiz was scoped to the current school from the start

The controller only exposed active quizzes for the current school:

def index
  @quizzes = current_school.quizzes.active
  redirect_to start_quiz_path(@quizzes.first) if @quizzes.count == 1
end

And it also guarded against school mismatches:

def require_correct_school
  if current_school.blank? || quiz_school_mismatch
    redirect_to root_path
  end
end

def quiz_school_mismatch
  @quiz && @quiz.school != current_school
end

I like this because it kept the feature aligned with the actual product model.

This wasn't one giant public quiz catalog. A Columbia quiz belonged on Columbia's subdomain. A different school got a different quiz, different branding, different outcomes, and eventually a different signup funnel.

That's the right call when the network boundary is the school.

Questions were weighted, not just counted

The scoring system didn't treat every question as worth the same amount. Each question had a value, and the score engine used that directly:

def radio
  if submitted_answer_id.present?
    Answer.find(submitted_answer_id).correct ? submitted_question.value : 0
  elsif no_correct_answers?
    submitted_question.value
  else
    0
  end
end

That let the content team decide which questions were lightweight and which ones mattered more.

A generic quiz app usually counts raw correct answers. This one had room for editorial weighting. That's a better fit when the quiz is about school identity and not just random entertainment.

Checkbox questions got partial credit in a way I still like

The more interesting logic was the checkbox path:

def checkbox
  user_answers = Answer.where(id: submitted_answer_ids)
  user_correct_answers = user_answers.select {|a| a.correct}
  user_correct_non_answers = submitted_question.answers.where.not(id: user_answers.map(&:id), correct: true)
  one_answer_value = (submitted_question.value.to_f / submitted_question.answers.count.to_f).round(2)
  user_question_score = (user_correct_answers.count + user_correct_non_answers.count) * one_answer_value
end

Here's what that means in practice.

The question value gets split evenly across all available answers. Then the user earns points for two kinds of correctness:

  • selecting answers that are actually correct
  • leaving incorrect answers unselected

That's more interesting than a pass/fail checkbox question.

It means the quiz isn't only measuring whether someone spotted the right choices. It's also measuring whether they avoided the wrong ones. For a school-knowledge quiz, that feels fair. It rewards actual recognition instead of lucky over-clicking.

The UI moved one question at a time, but the score stayed cumulative

The quiz view was intentionally simple:

= form_tag submit_answer_quizzes_path(last_question: @current_question.id, total_score: @total_score), remote: true do
  .panel.panel-default
    .panel-heading
      .panel-title= @current_question.header

Each submit carried two things forward:

  • the last question id
  • the running total score

The controller picked up the submitted question, found the next one by position, and updated the total:

@submitted_question = Question.find(params[:last_question])
@quiz = Quiz.find(@submitted_question.quiz_id)
@questions = @quiz.questions.order(position: :asc)
@current_question = @questions[@questions.index(@submitted_question) + 1]

logic = QuizLogic.new({
  submitted_question: @submitted_question,
  answer: params[:answer],
  answer_ids: params[:answer_ids]
})

@total_score = params[:total_score].to_i + logic.get_current_score

Then the frontend swapped the panel with JS:

$("#quiz-view").empty();
$("#quiz-view").html("<%= j render 'question' %>");

I like this structure because it kept the interaction lightweight without turning the whole feature into a client-side app. Rails still owned the sequence, the question order, and the scoring logic. The browser just moved the user through the flow.

That's a practical split.

The result screen was designed to keep the loop going

Once the quiz ended, the app stored a QuizScore, calculated the percentage against the total weighted value of the quiz, and generated share copy:

def self.score_percent(score, quiz)
  score / (quiz.questions.pluck(:value).reduce(:+).to_f) * 100
end

def self.score_title(score_percent, school)
  "My score... #{score_percent.round}% on the #{school.informal_name} quiz"
end

The score page then did three useful things:

  • showed the user's result
  • offered Facebook sharing
  • pushed the user toward signup

It also had a nice little hook: if someone entered their email address, the app would send a reminder showing the average score for that quiz.

@average_score = @quiz.quiz_scores.average(:score).to_f
@average_percent = QuizLogic.score_percent(@average_score, @quiz).to_i

That's better than a dead-end result screen.

Instead of "thanks, you're done," the feature became "here's your score, here's how other people do, and here's the next step if you want access to the actual community."

The reminder email and signup path preserved attribution

The reminder flow was pretty direct:

@email = SignUpReminderEmail.create(reminder_email_params)
UserMailer.signup_reminder_email(@email.id, current_school.id).deliver

And the email linked back into the school with quiz context:

#{link_to 'Sign Up', root_url(subdomain: current_school.subdomain, quiz: true)}

From there the app recorded that the user came from a quiz.

The school page stashed that in the session:

def check_if_came_from_quiz
  if params[:from_quiz].present? && current_school.present?
    session[:from_quiz] = true
  end
end

Then account creation copied it onto the user:

def determine_if_user_came_from_quiz
  params[:user][:from_quiz] = session[:from_quiz]
end

That's the part I like most.

The quiz wasn't just engagement bait. It was wired into attribution cleanly enough that the admin side could measure what happened next.

The admin panel tracked whether the funnel worked

The reporting object for quizzes tracked more than completions:

def get_profiles_from_quizzes_hash
  profiles_from_quizzes = Profile.joins(:user).where(users: {from_quiz: true}).uniq
  @profiles_from_quizzes_hash["_all_time"] = profiles_from_quizzes.count
  get_single_data_hash({
    all_time_klass: profiles_from_quizzes,
    hash: @profiles_from_quizzes_hash
  })
end

There was a matching method for validated profiles too.

That closed the loop:

  • people take the quiz
  • some ask for the reminder email
  • some click through to signup
  • some finish registration
  • some get validated into the network

That's a full product funnel, not just a scoring toy.

I still like the logic here

The best part of this feature is that it respected the product.

It was school-specific. It had weighted questions. It handled radio and checkbox scoring differently. It stayed server-driven. It turned quiz completions into measurable signup intent.

A lot of quiz features stop at "you got 8 out of 10."

This one was trying to turn school familiarity into community entry. That's more interesting.