Quiz Application

State machines in action. Timer, scoring, progress, and a polished flow.

State Machine

The quiz has distinct screens: start, playing, results.

// Quiz states: "start" → "playing" → "results"
// Each state shows different UI.

const state = {
  screen: "start",       // "start" | "playing" | "results"
  questions: [],
  currentIndex: 0,
  score: 0,
  answers: [],           // user's answers for review
  timeRemaining: 0,
  timerId: null,
};

// Question data structure:
const questions = [
  {
    id: 1,
    question: "What does 'typeof null' return in JavaScript?",
    options: ["null", "undefined", "object", "number"],
    correctIndex: 2,
    explanation: "This is a known bug in JS. typeof null returns 'object' due to how types were originally represented in binary.",
  },
  {
    id: 2,
    question: "Which method creates a new array from calling a function on every element?",
    options: ["forEach()", "map()", "filter()", "reduce()"],
    correctIndex: 1,
    explanation: "map() returns a new array. forEach() returns undefined.",
  },
  // ... more questions
];

// Shuffle questions for variety:
function shuffleArray(array) {
  const shuffled = [...array];
  for (let i = shuffled.length - 1; i > 0; i--) {
    const j = Math.floor(Math.random() * (i + 1));
    [shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
  }
  return shuffled;
}

Question Flow

Display questions one at a time with answer feedback.

// Start the quiz:
function startQuiz() {
  state.screen = "playing";
  state.questions = shuffleArray(questions).slice(0, 10); // 10 questions
  state.currentIndex = 0;
  state.score = 0;
  state.answers = [];
  state.timeRemaining = 30; // seconds per question
  render();
  startTimer();
}

// Render current question:
function renderQuestion() {
  const q = state.questions[state.currentIndex];
  const total = state.questions.length;

  document.querySelector(".quiz-area").innerHTML = `
    <div class="progress">
      <div class="progress-bar" style="width: ${((state.currentIndex) / total) * 100}%"></div>
      <span>${state.currentIndex + 1} / ${total}</span>
    </div>
    <div class="timer">${state.timeRemaining}s</div>
    <h2 class="question">${escapeHtml(q.question)}</h2>
    <div class="options">
      ${q.options.map((opt, i) => `
        <button class="option" data-index="${i}">
          <span class="letter">${String.fromCharCode(65 + i)}</span>
          ${escapeHtml(opt)}
        </button>
      `).join("")}
    </div>
  `;
}

Each question shows a progress bar, timer, question text, and multiple choice options.

1 / 2

Timer

Countdown per question — auto-skip when time runs out.

// Timer logic:
function startTimer() {
  state.timeRemaining = 30;
  updateTimerDisplay();

  state.timerId = setInterval(() => {
    state.timeRemaining--;
    updateTimerDisplay();

    if (state.timeRemaining <= 0) {
      timeUp();
    }
  }, 1000);
}

function stopTimer() {
  clearInterval(state.timerId);
  state.timerId = null;
}

function timeUp() {
  stopTimer();
  const q = state.questions[state.currentIndex];

  // Record as unanswered:
  state.answers.push({
    question: q.question,
    selected: -1, // no answer
    correct: q.correctIndex,
    isCorrect: false,
  });

  // Show correct answer:
  document.querySelectorAll(".option").forEach(opt => {
    opt.disabled = true;
    if (parseInt(opt.dataset.index) === q.correctIndex) {
      opt.classList.add("correct");
    }
  });

  // Flash timer red:
  const timerEl = document.querySelector(".timer");
  timerEl.classList.add("expired");
  timerEl.textContent = "Time's up!";

  setTimeout(() => nextQuestion(), 2000);
}

function updateTimerDisplay() {
  const timerEl = document.querySelector(".timer");
  timerEl.textContent = `${state.timeRemaining}s`;
  // Visual urgency when low:
  timerEl.classList.toggle("warning", state.timeRemaining <= 10);
  timerEl.classList.toggle("danger", state.timeRemaining <= 5);
}

function nextQuestion() {
  state.currentIndex++;
  if (state.currentIndex >= state.questions.length) {
    endQuiz();
  } else {
    state.timeRemaining = 30;
    renderQuestion();
    startTimer();
  }
}

Scoring

Track correct answers and calculate final percentage.

// Score calculation:
function getScorePercentage() {
  return Math.round((state.score / state.questions.length) * 100);
}

function getGrade(percentage) {
  if (percentage >= 90) return { grade: "A+", message: "Outstanding!", emoji: "🏆" };
  if (percentage >= 80) return { grade: "A", message: "Excellent!", emoji: "🌟" };
  if (percentage >= 70) return { grade: "B", message: "Good job!", emoji: "👍" };
  if (percentage >= 60) return { grade: "C", message: "Not bad!", emoji: "📚" };
  return { grade: "D", message: "Keep practicing!", emoji: "💪" };
}

// Track stats over multiple attempts:
function saveStats() {
  const stats = JSON.parse(localStorage.getItem("quiz-stats") || "[]");
  stats.push({
    date: Date.now(),
    score: state.score,
    total: state.questions.length,
    percentage: getScorePercentage(),
  });
  // Keep last 20 attempts:
  if (stats.length > 20) stats.shift();
  localStorage.setItem("quiz-stats", JSON.stringify(stats));
}

// Streak tracking:
function getStreak() {
  let streak = 0;
  for (const answer of state.answers) {
    if (answer.isCorrect) streak++;
    else streak = 0;
  }
  return streak;
}

function getBestStreak() {
  let best = 0, current = 0;
  for (const answer of state.answers) {
    if (answer.isCorrect) { current++; best = Math.max(best, current); }
    else current = 0;
  }
  return best;
}

Results

Summary screen with score, review, and play-again option.

function endQuiz() {
  stopTimer();
  state.screen = "results";
  saveStats();
  renderResults();
}

function renderResults() {
  const percentage = getScorePercentage();
  const { grade, message, emoji } = getGrade(percentage);
  const bestStreak = getBestStreak();

  document.querySelector(".quiz-area").innerHTML = `
    <div class="results">
      <div class="score-circle">
        <span class="emoji">${emoji}</span>
        <span class="percentage">${percentage}%</span>
        <span class="grade">${grade}</span>
      </div>
      <h2>${message}</h2>
      <p class="score-text">
        ${state.score} of ${state.questions.length} correct
      </p>
      <p class="streak">Best streak: ${bestStreak} in a row 🔥</p>

      <div class="review">
        <h3>Review Answers</h3>
        ${state.answers.map((a, i) => `
          <div class="review-item ${a.isCorrect ? "correct" : "wrong"}">
            <span class="icon">${a.isCorrect ? "✓" : "✗"}</span>
            <span class="q-text">${escapeHtml(a.question)}</span>
          </div>
        `).join("")}
      </div>

      <div class="actions">
        <button class="play-again">Play Again</button>
        <button class="share-btn">Share Score</button>
      </div>
    </div>
  `;
}

// Play again:
document.querySelector(".quiz-area").addEventListener("click", (e) => {
  if (e.target.closest(".play-again")) startQuiz();
  if (e.target.closest(".share-btn")) shareScore();
});

// Share via Web Share API:
async function shareScore() {
  const text = `I scored ${getScorePercentage()}% on the JS Quiz! 🎯`;
  if (navigator.share) {
    await navigator.share({ title: "Quiz Score", text });
  } else {
    await navigator.clipboard.writeText(text);
    alert("Score copied to clipboard!");
  }
}

// Key patterns in this project:
// ✓ State machine (start → playing → results)
// ✓ Timer with setInterval + cleanup
// ✓ Event delegation for dynamic buttons
// ✓ Score tracking + localStorage stats
// ✓ Shuffled questions for replayability
// ✓ Visual feedback (colors, animations)
// ✓ Web Share API for native sharing

FAQ

Common questions about this project.