JavaScript Quiz

Build a JavaScript quiz app: question state, scoring logic and a dynamic UI, written a step at a time in plain JavaScript.

Part of the free JavaScript course at LearnCodingFast — hands-on lessons with examples you run in your browser, plus practice exercises and a quick quiz.

Intermediate Project — Master Logic, State Management & Dynamic UI

Project Overview

You are building a single-page quiz that runs entirely in the browser with no server, no framework and no build step — one HTML file containing ten questions, about eighty lines of JavaScript, and a stylesheet. Open it and the first question appears with four clickable options. Click one and it turns green if you were right, red if you were wrong, and in the wrong case the correct option lights up green beside it so you learn something instead of just losing a point. The other options stop responding to clicks, a "Next Question" button appears, and a bar across the top slides forward. After the tenth question the whole question area is replaced by a results screen showing your score and a percentage.

What makes this a genuinely instructive project is that the page has no markup for the questions at all. Look at the HTML and you will find one empty <div> for the question text and one empty container for the options. Every visible word is created by JavaScript at run time from an array of objects. That is the leap from "a web page with a script attached" to "a program that renders a user interface", and it is the same leap React, Vue and Svelte make for you behind the scenes. Doing it by hand once, with plain createElement and appendChild, is what makes those frameworks make sense later.

A Quiz App is the perfect project for learning:

This exact pattern is used in apps like:

You'll build a fully interactive quiz engine that shows one question at a time, tracks score, displays progress, and shows the final results screen.

Anyone who needs to check whether a reader understood something reaches for this shape: a teacher building a revision drill, a bootcamp checking a cohort before the next module, a developer adding an onboarding knowledge check to an internal tool, or you, turning your own study notes into something you can retake until the score stops moving. The visual polish of the commercial products is different; the engine underneath is the one you are about to write.

Goal

Your quiz app should:

Read that list as seven separate acceptance tests rather than one big feature. Each one can be checked on its own: does exactly one question show at a time, does a second click on an already-answered question do nothing, does the score after three correct answers read 3. Building in that order — get the question on screen, then get a click to register, then grade it, then count it, then end the quiz — means you always have a working program in front of you, and any breakage came from the twenty lines you just wrote rather than from somewhere in the whole file.

Once you finish this, you'll understand exactly how to build logic-heavy apps.

Core Concepts You'll Learn

Five ideas carry this whole project. None of them is exotic, but the quiz forces you to hold all five at once, which is exactly why it teaches more than five separate exercises would.

1. State management

State is the small set of values that describe what the app is doing right now, as opposed to the questions themselves, which never change. In this project the entire state is two numbers: which question you are on, and how many you have got right. Everything the user sees is derived from those two numbers plus the question array, which means that if the screen ever looks wrong you only have two values to inspect — and it also means resetting the quiz is just setting them both back to zero.

2. Conditional rendering

Conditional rendering means deciding, in code, which parts of the page exist or are visible at a given moment. This quiz uses the simplest form of it: both the question screen and the results screen are always in the HTML, and the script flips their style.display between "none" and "block" so only one is on screen. Hiding rather than deleting is deliberate — the results screen keeps its structure and ids, so showing it again after a restart costs one line instead of rebuilding it.

3. Event handling

An event handler is a function you hand to the browser to run later, when something happens. Nothing in this quiz runs on a schedule; every change after the first question is drawn is the direct consequence of a click. The subtle part is that the option handlers are attached to elements that did not exist a moment ago and will be thrown away when the next question loads, so the handlers have to be attached inside the same function that creates the elements.

4. Dynamic UI updates

Dynamic UI means the browser is told what to show by your code rather than by markup you typed. Three different techniques appear here and it is worth noticing that they are different: innerText writes a string into an element, createElement plus appendChild build brand new elements, and classList.add changes how an existing element looks by handing the styling decision back to CSS. That last one matters: the JavaScript says "this answer was correct" and the stylesheet decides what correct looks like.

5. Array iteration

The questions live in an array of objects, and the code reaches into it in two ways. Indexing — questions[index] — picks the one question that is currently on screen, while forEach walks that question's options to build a clickable element for each. Storing the correct answer as a number, the position of the right option, rather than as the answer text is the design decision that makes grading a single === comparison and lets you shuffle or reword options later without breaking anything.

Quiz questions stored as objects:

Build It Step by Step

The full file is below, but reading eighty lines at once teaches very little. Here is the same program in nine pieces, in the order you would write them. Each piece is short enough to type from memory once you understand why it is shaped the way it is.

Step 1 — The question bank

Data first. Every question is an object with the same three keys, and that sameness is the point: because all ten have identical shape, one function can render any of them. The answer key is not the correct text — it is the position of the correct entry inside options, counting from zero.

const questions = [
  {
    question: "Which keyword declares a variable in JavaScript?",
    options: ["var", "let", "const", "All of the above"],
    answer: 3
  },
  {
    question: "What does DOM stand for?",
    options: ["Document Object Model", "Data Object Method", "Digital Ordinance Model", "Desktop Object Mode"],
    answer: 0
  }
  // ...eight more, same shape
];

The tempting mistake is storing the answer as text — answer: "var". It reads better, but it turns every grade into a string comparison, so a trailing space or a changed capital letter silently marks a right answer wrong. The index-based version has its own trap: it is zero-based, so answer: 3 on a four-option question means the fourth option. An off-by-one here is invisible — the quiz runs perfectly and just grades wrongly — so check each entry by counting the options out loud before you move on.

Step 2 — The state, all two lines of it

These two variables are the entire memory of the app. They sit at the top level of the script, outside every function, so all the functions read and write the same copy. let rather than const because both are meant to change.

let index = 0;
let score = 0;

Declare either with const and the first index++ throws TypeError: Assignment to constant variable. The subtler mistake is adding a third variable to cache "the current question object". Deriving it fresh with questions[index] every time keeps one source of truth; a cached copy is a second one, and sooner or later the two disagree.

Step 3 — loadQuestion(): draw the screen

This is the render function, and it is the heart of the project. It reads the question at the current index and paints it: text into the question div, then one new <div> per option, each given the option class and its own click handler. The handler is created inside forEach so it can close over that option's own i — that is how each div remembers which option it is.

function loadQuestion() {
  const q = questions[index];

  document.getElementById("questionText").innerText = q.question;

  const optionsContainer = document.getElementById("optionsContainer");
  optionsContainer.innerHTML = "";

  q.options.forEach((opt, i) => {
    const div = document.createElement("div");
    div.classList.add("option");
    div.innerText = opt;
    div.onclick = () => selectOption(i, div);
    optionsContainer.appendChild(div);
  });

  document.getElementById("nextBtn").style.display = "none";

  updateProgress();
}

Delete optionsContainer.innerHTML = "" and you get the classic version of this bug: question two's options are appended underneath question one's, and by the end the page is showing forty options. That one line is also why stale green and red highlights never leak between questions — the coloured elements are destroyed, not repaired. And hiding the Next button on every render is what forces the user to answer before advancing; the button is only put back by selectOption.

Step 4 — selectOption(): grade the click

This runs when an option is clicked. It receives two things: which option it was (i) and the actual element that was clicked, so it can colour that specific div without having to search for it again. First it locks the whole row of options, then it compares and colours, then it reveals the Next button.

function selectOption(i, element) {
  const correctIndex = questions[index].answer;

  document.querySelectorAll(".option").forEach(opt => {
    opt.style.pointerEvents = "none";
  });

  if (i === correctIndex) {
    element.classList.add("correct");
    score++;
  } else {
    element.classList.add("wrong");
    document.querySelectorAll(".option")[correctIndex].classList.add("correct");
  }

  document.getElementById("nextBtn").style.display = "block";
}

The pointerEvents = "none" loop is the guard rail, and it runs before the grading rather than after for a reason: without it, clicking the correct option four times adds four points and a user can finish with a score higher than the number of questions. The second thing to watch is querySelectorAll(".option")[correctIndex]. That line assumes the divs on screen sit in exactly the same order as the strings in the options array. It is true here because loadQuestion appended them in order — but the moment you add option shuffling, that assumption breaks and the app confidently highlights the wrong option green.

Step 5 — nextQuestion(): advance or finish

Three lines that decide whether the quiz continues. Move the pointer forward, ask whether we have run out of questions, and either show the results or render the next one. This is the only place in the program where the quiz can end.

function nextQuestion() {
  index++;
  if (index >= questions.length) return showResults();
  loadQuestion();
}

return showResults(); looks strange because showResults returns nothing useful. The return is there to stop the function, not to hand back a value. Drop it and loadQuestion() runs anyway with index equal to 10, questions[10] is undefined, and the console shows TypeError: Cannot read properties of undefined (reading 'question'). Writing it as a full if / else is clearer if the one-liner bothers you; what matters is that exactly one branch runs.

Step 6 — updateProgress(): the bar

The progress bar is two nested divs: a grey track with overflow: hidden and a coloured inner bar whose width starts at 0%. Because the CSS on that inner bar carries transition: width 0.3s ease, JavaScript only has to set a new width and the browser animates the change for free. The width is recomputed from index rather than incremented, so it can never drift out of step with the quiz.

function updateProgress() {
  const percentage = ((index) / questions.length) * 100;
  document.getElementById("progressBar").style.width = percentage + "%";
}

Read that arithmetic carefully, because it has a visible consequence people usually discover by accident. The bar shows questions completed, not questions reached: on question one index is 0 so the bar is empty, and when the tenth question appears index is 9, so it sits at 90% and never fills before the results screen takes over. If you would rather it be full on the last question, use (index + 1) / questions.length. Both are defensible; shipping one without knowing which is not. Note also the + "%" — a bare number assigned to style.width is not a valid CSS length and is ignored silently, with no error to tell you why nothing moved.

Step 7 — showResults(): swap the screen

Both screens exist in the HTML from the start; this function just changes which one is visible, then writes the score line. Working out the percentage here rather than storing it as the quiz runs means there is nothing to keep updated — it is derived from score at the moment it is needed.

Math.round is not decoration. With ten questions every score divides cleanly, but shorten the quiz to three and 2 out of 3 becomes 66.66666666666666 — a results screen showing that looks broken to a user even though the maths is right. The backtick string is a template literal: everything inside ${ } is evaluated as JavaScript and dropped into the text. Using ordinary quotes here is the single most common typo in this file, and it produces no error at all — the user simply sees the literal text You scored ${score} on screen.

Step 8 — restartQuiz(): back to zero

Reset both state variables, put the screens back the way they started, and render question one. Five statements in total, and it is worth pausing on how little they have to do.

function restartQuiz() {
  index = 0;
  score = 0;

  document.getElementById("quizScreen").style.display = "block";
  document.getElementById("resultScreen").style.display = "none";

  loadQuestion();
}

Notice what is missing. Nothing clears the green and red classes, nothing re-enables pointer events, nothing resets the progress bar — because loadQuestion throws the old option elements away and builds new ones, and it calls updateProgress with the index already back at 0. That is the whole payoff of keeping state in two numbers: reset is those two numbers plus a re-render. An app that instead recorded its state by writing classes onto elements would need one undo for every visual change it ever made, and it would always miss one.

Step 9 — Start it

One line at the very bottom of the script, outside every function. Nothing is on screen until it runs.

loadQuestion();

Where the whole <script> sits is load-bearing. It is the last thing before </body>, so by the time it runs the divs it looks for already exist. Move it into <head> without defer and getElementById("questionText") returns null, giving TypeError: Cannot set properties of null (setting 'innerText') before a single question is drawn.

Starter Code — Complete Quiz App

Here are all nine steps assembled into one file, with the stylesheet and markup they need. Save it as quiz.html and open it in any browser — there is nothing to install and no server to run. Read the HTML first and count how few elements there are; almost everything you will see on screen is created by the script.

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Quiz App</title>

<style>
  body {
    background: #eef1ff;
    font-family: 'Segoe UI', sans-serif;
    display: flex;
    justify-content: center;
    padding: 20px;
  }
  .quiz-container {
    width: 450px;
    background: white;
    padding: 25px;
    border-radius: 15px;
    box-shadow: 0 10px 40px rgba(0,0,0,0.15);
  }
  h1 {
    text-align: center;
    margin-bottom: 20px;
    color: #4a52ff;
  }
  .progress {
    background: #ddd;
    height: 8px;
    border-radius: 4px;
    overflow: hidden;
    margin-bottom: 20px;
  }
  .progress-bar {
    height: 100%;
    width: 0%;
    background: #4a52ff;
    transition: width 0.3s ease;
  }
  .question {
    font-size: 20px;
    margin-bottom: 20px;
  }
  .option {
    background: #f3f3f3;
    padding: 12px;
    border-radius: 8px;
    margin-bottom: 12px;
    cursor: pointer;
    border: 2px solid transparent;
    transition: all 0.2s ease;
  }
  .option:hover { background: #e9e9e9; }
  .correct { background: #6bcf7f !important; color: white; border-color: #45b55a; }
  .wrong { background: #ff6b6b !important; color: white; border-color: #d64545; }
  button {
    background: #4a52ff;
    color: white;
    padding: 12px 20px;
    border-radius: 10px;
    border: none;
    cursor: pointer;
    font-size: 16px;
    width: 100%;
    display: none;
    margin-top: 15px;
  }
  #resultScreen {
    text-align: center;
    display: none;
  }
  #resultScreen h2 {
    font-size: 28px;
    color: #333;
  }
  #resultScreen p {
    margin-top: 10px;
    font-size: 18px;
  }
</style>
</head>

<body>

<div class="quiz-container">

  <h1>JavaScript Quiz</h1>

  <!-- Progress Bar -->
  <div class="progress">
    <div class="progress-bar" id="progressBar"></div>
  </div>

  <!-- Question Screen -->
  <div id="quizScreen">
    <div class="question" id="questionText"></div>
    <div id="optionsContainer"></div>
    <button id="nextBtn" onclick="nextQuestion()">Next Question</button>
  </div>

  <!-- Results Screen -->
  <div id="resultScreen">
    <h2>Quiz Completed!</h2>
    <p id="scoreText"></p>
    <button onclick="restartQuiz()">Restart Quiz</button>
  </div>

</div>

<script>
const questions = [
  {
    question: "Which keyword declares a variable in JavaScript?",
    options: ["var", "let", "const", "All of the above"],
    answer: 3
  },
  {
    question: "What does DOM stand for?",
    options: ["Document Object Model", "Data Object Method", "Digital Ordinance Model", "Desktop Object Mode"],
    answer: 0
  },
  {
    question: "Which method converts JSON into an object?",
    options: ["JSON.stringify()", "JSON.parse()", "JSON.object()", "JSON.toObject()"],
    answer: 1
  },
  {
    question: "Which symbol is used for arrow functions?",
    options: ["=>", "-->", "<--", "<>"],
    answer: 0
  },
  {
    question: "What is the result of '5' + 5?",
    options: ["10", "55", "Error", "undefined"],
    answer: 1
  },
  {
    question: "Which loop runs at least once?",
    options: ["for", "while", "do...while", "forEach"],
    answer: 2
  },
  {
    question: "Which company created JavaScript?",
    options: ["Microsoft", "Google", "Netscape", "Oracle"],
    answer: 2
  },
  {
    question: "Which array method adds items to the end?",
    options: ["push()", "pop()", "shift()", "unshift()"],
    answer: 0
  },
  {
    question: "What does '===' check?",
    options: ["Value only", "Type only", "Value & type", "Nothing"],
    answer: 2
  },
  {
    question: "Which is NOT a JavaScript data type?",
    options: ["String", "Number", "Float", "Boolean"],
    answer: 2
  }
];

let index = 0;
let score = 0;

function loadQuestion() {
  const q = questions[index];

  document.getElementById("questionText").innerText = q.question;

  const optionsContainer = document.getElementById("optionsContainer");
  optionsContainer.innerHTML = "";

  q.options.forEach((opt, i) => {
    const div = document.createElement("div");
    div.classList.add("option");
    div.innerText = opt;
    div.onclick = () => selectOption(i, div);
    optionsContainer.appendChild(div);
  });

  document.getElementById("nextBtn").style.display = "none";

  updateProgress();
}

function selectOption(i, element) {
  const correctIndex = questions[index].answer;

  document.querySelectorAll(".option").forEach(opt => {
    opt.style.pointerEvents = "none";
  });

  if (i === correctIndex) {
    element.classList.add("correct");
    score++;
  } else {
    element.classList.add("wrong");
    document.querySelectorAll(".option")[correctIndex].classList.add("correct");
  }

  document.getElementById("nextBtn").style.display = "block";
}

function nextQuestion() {
  index++;
  if (index >= questions.length) return showResults();
  loadQuestion();
}

function updateProgress() {
  const percentage = ((index) / questions.length) * 100;
  document.getElementById("progressBar").style.width = percentage + "%";
}

function showResults() {
  document.getElementById("quizScreen").style.display = "none";
  document.getElementById("resultScreen").style.display = "block";

  const percent = Math.round((score / questions.length) * 100);

  document.getElementById("scoreText").innerText =
    `You scored ${score} / ${questions.length} (${percent}%)`;
}

function restartQuiz() {
  index = 0;
  score = 0;

  document.getElementById("quizScreen").style.display = "block";
  document.getElementById("resultScreen").style.display = "none";

  loadQuestion();
}

loadQuestion();
</script>

</body>
</html>

One thing to look for while you play with it: open your browser's developer tools, pick the Elements tab, and watch the options container as you move between questions. You will see four divs appear, get a correct or wrong class added, and then be replaced wholesale by four new ones. Seeing the DOM change under your own clicks is what makes the rest of front-end development click.

The Same Engine, Without a Browser

It is easy to believe the quiz "is" the DOM code, so here is the same state machine with the browser removed entirely. The question array, the two state variables and the rules in selectOption, nextQuestion and showResults are unchanged; only the output has moved from the screen to console.log, and the pointer-events lock has become a plain answered flag. The clicks are scripted so the run is identical every time — the second question is answered wrongly and then clicked again, to prove the lock works.

// The quiz engine with the browser taken away. Same state, same rules,
// console.log() standing in for the screen.

const questions = [
  { question: "Which method converts JSON into an object?",
    options: ["JSON.stringify()", "JSON.parse()", "JSON.object()"],
    answer: 1 },
  { question: "What is the result of '5' + 5?",
    options: ["10", "55", "Error"],
    answer: 1 },
  { question: "Which loop runs at least once?",
    options: ["for", "while", "do...while"],
    answer: 2 }
];

let index = 0;
let score = 0;
let answered = false;   // stands in for pointer-events: none

function loadQuestion() {
  answered = false;
  console.log("Progress bar: " + Math.round((index / questions.length) * 100) + "%");
  console.log("Q" + (index + 1) + ": " + questions[index].question);
}

function selectOption(i) {
  if (answered) {
    console.log("   (locked - this click does nothing)");
    return;
  }
  answered = true;

  const correctIndex = questions[index].answer;
  if (i === correctIndex) {
    score++;
    console.log("   picked '" + questions[index].options[i] + "' -> CORRECT (score " + score + ")");
  } else {
    console.log("   picked '" + questions[index].options[i] + "' -> WRONG");
    console.log("   real answer: '" + questions[index].options[correctIndex] + "'");
  }
}

function nextQuestion() {
  index++;
  if (index >= questions.length) return showResults();
  loadQuestion();
}

function showResults() {
  const percent = Math.round((score / questions.length) * 100);
  console.log("Quiz complete: " + score + " / " + questions.length + " (" + percent + "%)");
}

// A scripted play-through: right, then wrong (clicked twice), then right.
loadQuestion();
selectOption(1);
nextQuestion();

selectOption(0);
selectOption(1);      // too late - the question is already locked
nextQuestion();

selectOption(2);
nextQuestion();

// ✅ Expected output:
// Progress bar: 0%
// Q1: Which method converts JSON into an object?
//    picked 'JSON.parse()' -> CORRECT (score 1)
// Progress bar: 33%
// Q2: What is the result of '5' + 5?
//    picked '10' -> WRONG
//    real answer: '55'
//    (locked - this click does nothing)
// Progress bar: 67%
// Q3: Which loop runs at least once?
//    picked 'do...while' -> CORRECT (score 2)
// Quiz complete: 2 / 3 (67%)

Being able to separate the rules from the rendering like this is a real skill, not a trick. Logic with no DOM in it can be tested without a browser, reused in a Node script that grades a whole class's answers, or dropped into React later with the console.log calls swapped for state updates. When people say a codebase has its "business logic" tangled up in its UI, this is the untangling they mean.

Common Errors and What Causes Them

Open the browser console (F12, then the Console tab) and keep it open while you build. Almost everything that goes wrong in this project announces itself there, and the wording of the message tells you exactly which line to look at. These are the ones you will actually meet.

TypeError: Cannot set properties of null (setting 'innerText')

getElementById found nothing and returned null, and you then tried to write to it. Three causes, in order of likelihood: the id in the JavaScript does not match the id in the HTML (ids are case-sensitive — questiontext is not questionText); the script runs before the element exists, because you moved it above the markup; or you wrote getElementById("#questionText") with a hash. The hash belongs to querySelector; getElementById wants the bare id.

TypeError: Cannot read properties of undefined (reading 'question')

You asked for a question that does not exist, so questions[index] is undefined and .question has nothing to read from. It fires on the click after the last question, and it means the length guard in nextQuestion is missing or wrong — a very common variant is writing > where you need >=, which lets index 10 of a ten-item array slip through.

TypeError: Cannot read properties of undefined (reading 'classList')

This one comes from the wrong-answer branch of selectOption: querySelectorAll(".option")[correctIndex] came back empty. It means one of your answer values points past the end of its own options array — answer: 4 on a question with four options, where the valid indexes are 0 to 3. Note that it only appears when someone answers that question wrongly, which is why it can survive several test runs before you see it.

ReferenceError: nextQuestion is not defined

The Next button's onclick="nextQuestion()" attribute looks the function up as a global, and it is not one. This is what happens the moment you move the script into <script type="module"> or into a separate module file, because module code has its own scope instead of the global one. The fix is not to go back — it is to stop using inline onclick attributes and attach the handler from JavaScript with addEventListener, which is how the option divs already do it.

SyntaxError: Unexpected identifier 'DOM'

A double quote inside a double-quoted string ended it early: question: "What does "DOM" stand for?". Because it is a syntax error the whole script fails to parse, so the page stays blank rather than showing nine working questions and one broken one. Use single quotes inside double quotes, or escape with a backslash. Its cousin, SyntaxError: Unexpected token '{', almost always means you forgot the comma between two question objects in the array.

Silent failure: everything is marked wrong

No error, no crash, just a score of 0 out of 10. You stored the answer as text while the click handler still passes a number, so i === correctIndex is comparing 2 with "do...while" and === is correctly telling you they are not the same. This is the bug the strict equality operator exists to expose; switching to == to make it "work" would only hide it.

Silent failure: the progress bar never moves

If you assign the raw number — style.width = percentage instead of percentage + "%" — the browser rejects it as an invalid CSS length and simply ignores the assignment. No exception is thrown and nothing appears in the console; the bar just sits there. Any time a style assignment seems to do nothing, inspect the element and check whether the property took the value at all.

Silent failure: the Restart button never appears

This one is in the starter file above, and finding it is a genuinely useful exercise. The stylesheet contains a blanket button { display: none; } rule so that the Next button starts hidden — but that rule matches every button on the page, including Restart, and the script only ever switches #nextBtn back to block. Showing the results screen does not un-hide the button inside it. Either narrow the rule to #nextBtn or add #resultScreen button { display: block; }. The lesson generalises: a bare element selector in CSS is a rule about every element of that type, now and in every part of the page you add later.

Why the feedback colours use !important

.correct and .wrong both set background with !important, which usually deserves suspicion. Here it is defensive: a single class selector like .correct is less specific than .option:hover, so without it a hover rule could repaint an answer you had just marked green. Understanding why a declaration needed the escape hatch is the difference between using CSS and fighting it.

Enhancement Ideas

Add these to turn your Quiz App into a real platform:

Take them one at a time and make sure the quiz still plays end to end after each one. Every item below touches a different part of the design, and the order they are listed in is roughly easiest to hardest.

⏲ Timer

Countdown per question (10–20 seconds).

Start a setInterval at the end of loadQuestion and keep the id it returns in a variable, because you must call clearInterval on it in two places: when the timer hits zero, and when the user answers early. Forgetting the second one is the bug everybody hits — the old timers keep running underneath, and by question five the countdown is jumping several seconds at a time because four intervals are ticking at once. Treat a timeout as a wrong answer by calling your grading function with an option index that cannot match.

🧩 Categories

Add a category key to every question object, then build the playing set with filter before the quiz starts rather than skipping questions during it. That keeps questions.length meaningful, so the progress bar and the final percentage stay correct without any extra work — a good demonstration of why deriving values from one array beats maintaining separate counters.

🎚 Difficulty Levels

Easy → Medium → Hard progression.

The interesting version is not a difficulty picker but adaptive difficulty: sort the questions by level and choose the next one based on how the last few went. That forces a real change to your state — you now need to remember which questions have already been asked, so a single index is no longer enough and you will reach for an array of asked ids or a Set. Feeling that pressure on your data model is the point of the exercise.

🏆 Leaderboard

Store high scores using localStorage.

Browser storage only holds strings, so the pattern is JSON.stringify on the way in and JSON.parse on the way out. Two things bite here. First, the very first read returns null because nothing has been saved yet, so fall back to an empty array before you try to sort it. Second, anything a user typed goes back onto the page later — put a player's name on screen with innerText, never innerHTML, so the text stays text instead of being parsed as markup.

📜 Review Mode

Show all questions with correct answers at the end.

This one forces a real design decision, which is why it is worth doing. Right now a click is graded and then forgotten — score++ throws away everything except the total. To review, you have to record what was chosen, so push the picked index onto an answers array inside selectOption. Once you have that array the score itself becomes derivable rather than tracked, and you have quietly rediscovered why real apps keep an event log instead of only a running total.

🔀 Question Randomization

Shuffling the questions is safe: each object carries its own answer index, so moving whole objects around changes nothing. Shuffling the options is the trap, and it is the one enhancement that can silently corrupt your quiz, because answer is a position and you have just moved everything out of position. Read the correct option's text before you shuffle, then use indexOf to find where it landed and write that back into answer. Use a Fisher–Yates shuffle rather than sort(() => Math.random() - 0.5), which is a well-known way to get a biased ordering.

🌐 API Integration

Use the Open Trivia Database:

Fetching questions turns a synchronous program into an asynchronous one, and that is the real lesson here. A remote source will not hand you objects in your shape, so write one small function whose only job is to convert whatever arrives into the { question, options, answer } form your engine already understands. Everything downstream then stays exactly as it is. You will also need to handle the two states that did not exist before — the moment before the data arrives, and the request failing — which is why real apps show a loading state and an error message.

Next Steps

When the quiz plays end to end and you can explain every line of it, these are the moves that turn it from an exercise into something you would show someone.

Related lessons