Building A Quiz App In Python Using Streamlit

Fesomade Alli
16 min readDec 11, 2023

--

Link To The Live Quiz App (To navigate to the quiz section on the live app, just click the test your knowledge section on the App sidebar).

While I was working on a sport analytics project for my alma-matter, I had done all the analysis I wanted and structured the app the way I wanted the user to interact with it, and I was pretty comfortable with what I had done. Then it struck me that if I were to be the user, I would only use the app whenever I need to know how my school team has fared over the years against different teams and/or across several competitions and once that is done, I’m off! (at least until a later time when I need such info again). It’ll be like doing a Google search and once I find what I need, that’s it!

I now started to think of how to engage the audience. I wanted to further tell stories that couldn’t necessarily be visualized, I wanted the user to keep coming back and that was where I thought of adding a quiz feature to my app. I thought with this, users could test their knowledge and compete with colleagues and friends online by comparing their scores/rank on a Leaderboard (even Google has this dragon game you play with space bar whenever your network is down).

For you, there could be a different motive to build a quiz app. Perhaps, your small/medium organization needs to switch their interview tests from paper based tests to computer based exercises, if you are an educational group, your students could need a platform to take mock exams that would help them prepare for an upcoming examination and simulate the exam environment, you could be looking to build a quiz-based competition where users have their knowledge tested for a reward or you are a python developer just looking for something to build.

In this piece, I will show you how I built mine from scratch and share the link to the code and resources I used and even a few tips for upgrade so you could do even better!

When I started to build my app, I couldn’t find any sample online that was done using Streamlit (perhaps I didn’t search deeply enough) so I decided I would just build it from the scratch and I will share the steps in subsequent paragraphs. If you are new to Streamlit, you may use this link to get familiar with the python resource, it’s pretty simple, straightforward and easy to use. You should also consider joining the streamlit community, it would really help your journey and shorten your curve.

Planning the Quiz App

In my implementation, the:

  • User can read Instructions and follow a prompt to START the quiz
  • User can answer ten (10) MCQs (multiple-choice questions) randomly generated by the system from a pool of questions.
  • User can SUBMIT the quiz when they are done and view their final score and the correct answers to the questions with a little explanation as to why that was the most suitable answer to the question
  • User may RELOAD the quiz and the process recycles with a new set of questions.

Quiz App Layout

My app has its layout set to centered on the page configuration function of streamlit, read about st.set_page_config(). The quiz app starts with a title done with st.header(). The prompt for the quiz and the instructions are then formatted with st.markdown() so that they appear next to the title (items flowing from top to bottom). A button is placed next to the instruction box so the user can perform terminal actions like starting the quiz, submitting when done and reloading the questions. The labels on the button are managed using st.session_state(), in order to make it respond dynamically to user actions (details in next section).

The remainder of the quiz app is just multi and single-element containers (otherwise referred to as placeholders in this piece): the questions and their options are loaded in separate placeholders beneath the button and once the user submits, the feedback on each question and the explanation of the solution appears in separate placeholders beneath the set of options for each question and a final score is printed in a placeholder above the button.

According to streamlit functionalities, containers are a great way for making dynamic content and specifying where exactly on the page you would like what to appear. This is because even when single element containers are empty, they maintain their positions and hide well in plain sight — all you need do is just initialize the containers. You may then continue to modify what they hold or contain anywhere else in the program and the values you add to the placeholders would be displayed in the region where the single element container was created and it wouldn’t disrupt the flow of your program.

This feature, for me, make containers necessary for dynamic streamlit apps because streamlit runs and executes your program from top to bottom and changing something that is not in a placeholder may not necessarily have the desired effect. You may learn more about layouts in Streamlt here.

Code Flow & Explanation

A python script quiz.py was created and its content was a python list called sport_questions. The list contains 50 dictionary items with each dictionary corresponding to a single question.

sport_questions = [question_dict1, question_dict2, … question_dictN]

The content of each dictionary looks like this:

{
"question_number": 30,
"question": "Who captained the OAU MFT to their only NUGA Gold medal under Chike Egbunu-Olimene?",
"options": ["Seyi Olumofe", "Yengibiri Henry", "Addah Obubo", "Ayotunde Faleti"],
"correct_answer": "Ayotunde Faleti",
"explanation": "Ayotunde Faleti captained the OAU MFT to their only NUGA Gold medal under Chike Egbunu-Olimene in 2014."
}

In our main script, we need to import the script containing the questions at the top by just writing import quiz at the top of the script but we also need to import streamlit and we can just type import streamlit as st at the top of our code (the st is just for convenience otherwise we would need to type streamlit in full every time we need to use any of the components from it).

We should also note that streamlit does not have a whitespace function and elements are just printed next to each other in top-bottom fashion. We should therefore write a function that helps us insert whitespaces before or after streamlit components. With this, we can put spaces between items to be printed out on our page layout in streamlit so as to make them neat, think of it like adding top and bottom paddings to a component in matplotlib or css.

# newline char
def nl(num_of_lines):
for i in range(num_of_lines):
st.write(" ")

# in reality, we are really just printing whitespaces before/after
# components, leveraging on streamlit's top to botom rendering of components.
# the num_of_lines we want as spacing is passed into the nl() function
# and streamlit just prints rows of whitespaces before rendering the next
# component in our code.

Now, we will begin to print onto our screen.

import streamlit as st
st.header("Page Title")

# Add space between the geader and the next item
nl(1)

# Text Prompt
st.markdown("""
Write Quiz Description and Instructions.
""")

# Create Placeholder to print test score
scorecard_placeholder = st.empty()

Streamlit apps run from top to bottom on every refresh (or reruns as is used in streamlit terms) and every resource gets reset on each rerun. We however would need some resource to be useful across reruns and not reset; examples of such resource is the user selected option else, every time the user changes an option, the app will rerun the script from top to bottom. To protect resources across reruns, we leverage on a feature in streamlit called session states (defined in my code as ss for convenience). A quick tutorial on how to use session states can be found in this video.

Now that we have planned our app, we need to define the variables we would like to keep track of across reruns. This may be because we need to use them to modify the app behavior, for instance to return CORRECT or INCORRECT as feedback for a question the user has answered, or it could be to change the state of a widget or component as is the case when we changed the label on the button from START to SUBMIT and then to RELOAD depending on what the current state of the app was when the user clicked the button.

# Activate Session States
ss = st.session_state
# Initializing Session States
if 'counter' not in ss:
ss['counter'] = 0
if 'start' not in ss:
ss['start'] = False
if 'stop' not in ss:
ss['stop'] = False
if 'refresh' not in ss:
ss['refresh'] = False
if "button_label" not in ss:
ss['button_label'] = ['START', 'SUBMIT', 'RELOAD']
if 'current_quiz' not in ss:
ss['current_quiz'] = {}
if 'user_answers' not in ss:
ss['user_answers'] = []
if 'grade' not in ss:
ss['grade'] = 0

The syntax for defining and storing a value in session state is shown above, the syntax is very similar to defining a variable in python.

In the code snippet above, counter keeps track of the number of times the user clicks the button, it is initially set to 0. Ideally, the user shouldn’t need to click the button more than thrice per session: first to start the quiz, second to submit their answers and finally if they would like to reload the questions. At each point, there are certain behaviors expected from the quiz app and to correctly implement each of these behaviors, we need to know in which instance of the current Quiz has the user pressed the button.

The issue as briefed earlier is that for every action the user performs on the App, the script reruns from top to bottom and we would always have our counter value as 0 hence the need to store the value in session states. This way, no matter what the user does, if the value of counter is not updated, it will remain the same across reruns.

Similar to the counter variable, start, stop, refresh variables are to track the current instances of the user interaction with the button component by setting their boolean states to True whenever the user starts, submits or refreshes the exercise. They are all initialized to False to indicate that the user hasn’t taken any action.

The button_label is initialized as a list in the session state and it is to control what is printed on the button as a prompt for the user — whether to start, stop or to reload. The quiz is a set of ten (10) randomly generated sport questions from a pool of questions, hence the current_quiz variable, initialized as an empty dictionary, holds the current ten questions; on reload, a new set of questions are generated and then variable stores them all the same.

The user_answers, also initialized as an empty list, is a variable for categorizing the answers the users select in each question — whether CORRECT or INCORRECT. It holds ten boolean values corresponding to how each user selected option compares to the correct answer. In other words, the quiz is actually being graded as the user takes them, the answer just appears to them later!

The grade variable is only an integer count of correct answers from the user_answers list. As a quick note, session state variables can be defined, accessed or updated using two main methods: one is the dot notation and the other is the squared bracket notation, this means referring to a session state object as ss[‘grade’] or ss.grade, for instance, would accomplish the same programmatic objective.

In the function below, the button click is implemented. In the btn_click() function, the counter variable in session states increments by 1 corresponding to the number of clicks. The maximum number of clicks should ideally be 3 so whenever the user has not clicked the button three times, a function, update_session_state(), is called, and it updates the other variables in the session states accordingly. A spinner and a spinner text is added and a small time delay to give the user an impression of an interactive program window.

# Function for button click
def btn_click():
ss.counter += 1
if ss.counter > 2:
ss.counter = 0
ss.clear()
else:
update_session_state()
with st.spinner("*this may take a while*"):
time.sleep(2)

In the code snippet below, we peek into in the update_session_state() function. When counter=1, it means the user has hit the START button and as such the start variable is set to TRUE, and it is in this instance that we randomly load the ten (10) questions we want to present to the user onto the current_quiz variable in our session state, in this state it is expected that the user hit START. When counter=2, it is expected that the user is ready to SUBMIT, the variables start and stop are set to TRUE. When counter>2, the user is expected to have hit RELOAD and now we reset counter=0 and all variables in session state are cleared using the ss.clear() function.

# Function to update current session
def update_session_state():
if ss.counter == 1:
ss['start'] = True
ss.current_quiz = random.sample(quiz.sport_questions, 10)
elif ss.counter == 2:
# Set start to False
ss['start'] = True
# Set stop to True
ss['stop'] = True

In the code snippet below, the on_click parameter of the streamlit button component is used to consequently update the button label as evident in the label parameter. What happens is that, when a streamlit button component has an on_click parameter (the parameter only accepts a function), whenever the user clicks the button, that function passed in to the on_click parameter is run first before the entire app reruns, thus allowing you to quickly execute a few extra instructions before each app rerun. They key parameter is also a means of assigning a unique id for that particular button. You may read more about it here.

# Initializing Button Text
st.button(label=ss.button_label[ss.counter],
key='button_press', on_click= btn_click)

In the case of our Quiz App, you would recall our counter variable is initialized to 0 and our button_label variable is a finite list of string items namely: START, SUBMIT and RELOAD. Python’s method of accessing list items permits the use of indexes and python starts counting from 0, furthermore, the label parameter on the st.button() component corresponds to the string will be printed as the button label. Therefore, initially when the app launches (counter=0), the value of the label parameter will be the string item at index 0 of the button_label list (where 0 is the current value of counter).

This way, the text printed on the button changes from START (default value at counter=0) to SUBMIT (at counter=1) and then to RELOAD (at counter=2) correctly befitting every scenario as it may apply to a quiz session.

The state of the button labels at counter=0, counter=2 and counter=1 respectively

Finally let’s take a look at the main function that drives the quiz app.

# Function to display a question
def quiz_app():
# create container
with st.container():
if (ss.start):
for i in range(len(ss.current_quiz)):
number_placeholder = st.empty()
question_placeholder = st.empty()
options_placeholder = st.empty()
results_placeholder = st.empty()
expander_area = st.empty()
# Add '1' to current_question tracking variable cause python starts counting from 0
current_question = i+1
# display question_number
number_placeholder.write(f"*Question {current_question}*")
# display question based on question_number
question_placeholder.write(f"**{ss.current_quiz[i].get('question')}**")
# list of options
options = ss.current_quiz[i].get("options")
# track the user selection
options_placeholder.radio("", options, index=1, key=f"Q{current_question}")
nl(1)
# Grade Answers and Return Corrections
if ss.stop:
# Track length of user_answers
if len(ss.user_answers) < 10:
# comparing answers to track score
if ss[f'Q{current_question}'] == ss.current_quiz[i].get("correct_answer"):
ss.user_answers.append(True)
else:
ss.user_answers.append(False)
else:
pass
# Results Feedback
if ss.user_answers[i] == True:
results_placeholder.success("CORRECT")
else:
results_placeholder.error("INCORRECT")
# Explanation of the Answer
expander_area.write(f"*{ss.current_quiz[i].get('explanation')}*")

# calculate score
if ss.stop:
ss['grade'] = ss.user_answers.count(True)
scorecard_placeholder.write(f"### **Your Final Score : {ss['grade']} / {len(ss.current_quiz)}**")

As shown above, the quiz_app() function refers to everything else on the page as indicated in the layout document earlier discussed. To do this, we leverage the st.container() component in streamlit and put all our placeholders inside. A portion of the streamlit documentation explains single element containers in-depth, you may read it here.

One question requires four single element containers: the number_placeholder to hold the question number, the question_placeholder to hold the question itself, the options_placeholder to hold the multiple choices (options A to D), the results_placeholder to return the remarks of the user’s final selection on that question — whether CORRECT or INCORRECT and finally the expander_area for an explanation on why the option selected might be the most suitable answer or not.

The current_quiz variable already stored in session state is looped over to fill the respective single element containers with their corresponding portions of each dictionary item in the current_quiz variable that’s being looped over. In simpler terms, it means we use the get function of accessing python dictionaries to get the “question” to fill the question_placeholder, and similarly we use the .get(‘options’), .get (‘correct_answer’) and .get(‘explanation’) to fill the rest. The “question number” component of each dictionary item in current_quiz corresponds to the number originally belongs in the sport_questions pool hence we define our own custom numbering of 1–10 when looping over so our quiz has a sequential order.

Also, user_answers in session state compares the content of get(‘options’) with get(‘correct_answer’). If they are the same, a TRUE is appended and a FALSE if otherwise. A problem arises if we don’t control how values get appended to the list on submission. When the user submits, the number of TRUE values in user_answers are counted and printed as the score but on every refresh of the page or rerun of the App (without the user hitting the RELOAD button), one would see the user score increase in sequential multiples, that is, a user who originally scored 7/10 would now score 14/10 on reload and 21/10 on the next rerun and so on, even though they have already submitted their answers and ended the quiz session.

This is likely to happen because the app just checks again if there were any TRUE values in the last submitted set of answers and re-appends them (if any) to the user_answers list mindlessly. To curb this malperformance, we peg the limit of values to be held in the user_answers list to 10 — a number which tallies with the number of questions to be attempted by the user. This way, even on reload or rerun, nothing new gets added save what was already graded and appended there when the user submitted. Finally, to display the remarks of such categorizations, we leverage streamlit’s in-built feedback feature —st.success(), st.error() (you may see also st.warning())

"question_number": 30, 
"question": "Who captained the OAU MFT to their only NUGA Gold medal under Chike Egbunu-Olimene?",
"options": ["Seyi Olumofe", "Yengibiri Henry", "Addah Obubo", "Ayotunde Faleti"],
"correct_answer": "Ayotunde Faleti",
"explanation": "Ayotunde Faleti captained the OAU MFT to their only NUGA Gold medal under Chike Egbunu-Olimene in 2014."
}

Thanks for coming to my TedTalk!

If you made it this far, I really appreciate you, however, you could stick around for a few things I think would make your app even better.

Tips On How To Upgrade The App

Here are two quick tips that you could add to your own quiz app to make it more customized. If any of these features sounds interesting to you, you could try to implement them. You may start by checking out a few materials online (YouTube, Streamlit Community, Blogs etc.) and I’d definitely love to get your feedback and see what you’ve done.

1. Implement A Timer Function

You could add a timer to your quiz that counts down to zero for the each quiz session and times out if the user is yet to submit.

2. Implement A Leaderboard & Points System

You could add a points system for different questions (harder questions carry more points), To track the user score after each session, you could leverage on streamlit component to add a signup/login feature to your App and on login, update the user points or ratings on a global leaderboard after each round of tests.

3. Implement A Difficulty Level

You could use either a selectbox function or a slider component to prompt the use to decide the kind of questions they would want thrown at them.

Using a slider means you can select a range of questions, say a user wants a mix of easy and medium questions, or a mix of medium and difficult questions. With a selectbox however, user can only select one difficulty level per quiz session.

Depending on the method you implement, the code may need a little tweak but the question script could remain the same. In your code, you could define three lists of 50 python dictionaries, where every dictionary in each list is a potential question the user could be asked and each python list corresponds to a group of 50 questions for every intended difficulty level. As an illustration, for the question script, you could have something that looks like this:

# Define 50 "Easy" Quiz Questions
sport_questions_easy = [question_dict1, question_dict2, … question_dict50]

# Define 50 "Medium" Quiz Questions
sport_questions_medium = [question_dict1, question_dict2, … question_dict50]

# Define 50 "Difficult" Quiz Questions
sport_questions_difficult = [question_dict1, question_dict2, … question_dict50]

In both cases, you may need to pass a difficulty argument into your update_session_state() function to track whatever difficulty level the user has selected. If you used the selectbox for instance, your mode of implementation is similar to what we have used in this article.

# Function to update current session
def update_session_state(difficulty_level):
if difficulty_level == "Difficult":
question_script = quiz.sport_questions_difficult
if difficulty_level == "Medium":
question_script = quiz.sport_questions_medium
else:
question_script = quiz.sport_questions_easy

# Load 10 Random Questions When User Hits The START Button!
if ss.counter == 1:
ss['start'] = True
ss.current_quiz = random.sample(question_script, 10)

# code continues...

Peradventure you used the slider method, one would expect that your difficulty_level variable would be a range of values. You may check out streamlit’s st.select_slider() component for more information. You could try and extract the difficulty_level from the value argument of select_slider() component and pass it into the update_session_state() function as a dictionary, say: difficulty_level = { lower_level” : “Medium” , “upper_level” : “Difficult”}.

Your code could look thus:

# Difficulty Level Gotten From User Slider Selection
difficulty_level = { "lower_level" : "Medium",
"upper_level" : "Difficult"}

# Function to update current session
def update_session_state(difficulty_level):
# initiallize an empty question_script list to hold
# the questions loaded from each difficulty pool
# I am assuming a ratio 1:1 (5 Medium and 5 Difficulty Questions)
question_script = []

# fetch the question
for key in difficulty_level :
question_pool = f"sport_questions_{difficulty_level.get(key).lower()}"
question_script.append(random.sample(quiz.question_pool, 5)

# Load The 10 Random Questions When User Hits The START Button!
if ss.counter == 1:
ss['start'] = True
ss.current_quiz = question_script

# code continues...

That’ll be all for now, don’t forget to catch any errors you encounter, thank you for reading! 😉

--

--