The Codex / Projects / Quiz Engine / JavaScript
Tier 2 — Intermediate

Quiz Engine

A reusable quiz engine — timed questions, multiple choice and free-text, scoring, category tracking, export.

🐍 Python 🟨 JavaScript

1. The Problem

Build a reusable quiz engine that supports multiple question types (multiple-choice, true/false, free-text), optional time limits per question, category scoring, and exportable results. The engine is a pure class — the CLI is just one way to drive it.

Why this project? Class design with state, timer-based question limits using Promise.race, scoring by category, and a clean separation between the engine (reusable) and the presentation (CLI, browser, API).

2. How to Think About This

The QuizEngine holds questions and results. Each question has a type, the correct answer, a category, and an optional time limit. For timed questions, race a readline prompt against a setTimeout. Score tracking: total points + breakdown by category. Results are a plain array — serialisable to JSON for any use.

3. The Build

quiz-engine.mjs
import * as readline from "readline/promises";
import { stdin, stdout } from "process";

export class QuizEngine {
  #questions = [];
  #results   = [];

  addQuestion(q) {
    const defaults = { type:"mc", category:"General", points:1, timeLimit:null };
    this.#questions.push({ ...defaults, ...q });
    return this;  // chainable
  }

  get total()     { return this.#questions.length; }
  get answered()  { return this.#results.length; }

  score() {
    const byCategory = {};
    let total = 0, earned = 0;
    for (const r of this.#results) {
      total  += r.points;
      earned += r.pointsEarned;
      byCategory[r.category] ??= { earned: 0, total: 0 };
      byCategory[r.category].earned += r.pointsEarned;
      byCategory[r.category].total  += r.points;
    }
    return { earned, total, pct: total ? Math.round(earned / total * 100) : 0, byCategory };
  }

  results() { return [...this.#results]; }

  async run(rl) {
    for (const q of this.#questions) {
      const result = await this.#askOne(q, rl);
      this.#results.push(result);
    }
    return this.score();
  }

  async #askOne(q, rl) {
    console.log(`
Q: ${q.question}`);
    if (q.type === "mc") {
      q.options.forEach((opt, i) => console.log(`  ${i + 1}. ${opt}`));
    } else if (q.type === "tf") {
      console.log("  1. True   2. False");
    }
    if (q.timeLimit) console.log(`  ⏱ ${q.timeLimit}s time limit`);

    let answer;
    if (q.timeLimit) {
      const timeout = new Promise(r => setTimeout(() => r("__timeout__"), q.timeLimit * 1000));
      answer = await Promise.race([rl.question("  Answer: "), timeout]);
      if (answer === "__timeout__") { console.log("  ⏱ Time's up!"); answer = ""; }
    } else {
      answer = await rl.question("  Answer: ");
    }

    const correct = this.#check(q, answer.trim());
    const pointsEarned = correct ? q.points : 0;
    console.log(correct
      ? `  ✓ Correct! (+${q.points})`
      : `  ✗ Wrong. Answer: ${q.type === "mc" ? q.options[q.answer - 1] : q.answer}`);

    return { question: q.question, category: q.category, points: q.points,
             pointsEarned, correct, userAnswer: answer, timeLimit: q.timeLimit };
  }

  #check(q, answer) {
    if (q.type === "mc" || q.type === "tf") {
      return parseInt(answer) === q.answer;
    }
    // free-text: case-insensitive, trim
    return answer.toLowerCase() === String(q.answer).toLowerCase();
  }
}

// Demo quiz
const engine = new QuizEngine()
  .addQuestion({
    type: "mc", question: "What does typeof null return?",
    options: ["null","undefined","object","boolean"], answer: 3,
    category: "JS Quirks", points: 2
  })
  .addQuestion({
    type: "tf", question: "NaN === NaN evaluates to true.",
    answer: 2, category: "JS Quirks", points: 1
  })
  .addQuestion({
    type: "mc", question: "Which creates a deep copy?",
    options: ["spread (...)","Object.assign","structuredClone","JSON.parse only"], answer: 3,
    category: "JS Patterns", points: 2, timeLimit: 15
  });

const rl = readline.createInterface({ input: stdin, output: stdout });
console.log(`
=== JavaScript Quiz (${engine.total} questions) ===`);
const score = await engine.run(rl);
rl.close();

console.log(`
=== Results ===`);
console.log(`Score: ${score.earned}/${score.total} (${score.pct}%)`);
console.log("By category:");
Object.entries(score.byCategory).forEach(([cat, s]) =>
  console.log(`  ${cat}: ${s.earned}/${s.total}`));

4. Test & Prove

import { QuizEngine } from "./quiz-engine.mjs";
let p=0,f=0;
function test(d,fn){try{fn();console.log("  ✓",d);p++;}catch(e){console.log("  ✗",d,"—",e.message);f++;}}
// Test scoring directly by injecting results
const engine = new QuizEngine()
  .addQuestion({type:"mc",question:"Q1",options:["a","b"],answer:1,category:"Math",points:2})
  .addQuestion({type:"tf",question:"Q2",answer:2,category:"Science",points:1})
  .addQuestion({type:"text",question:"Q3",answer:"javascript",category:"Lang",points:3});
test("total questions", () => { if(engine.total!==3) throw new Error(engine.total); });
test("zero answered at start", () => { if(engine.answered!==0) throw new Error(); });
// Simulate the check method (private, but score() uses results())
const fakeResults = [
  {question:"Q1",category:"Math",points:2,pointsEarned:2,correct:true},
  {question:"Q2",category:"Science",points:1,pointsEarned:0,correct:false},
  {question:"Q3",category:"Lang",points:3,pointsEarned:3,correct:true},
];
// Access score through a subclass for testing (white-box)
class TestEngine extends QuizEngine {
  injectResults(results) { results.forEach(r => this.results().push && true); }
}
test("score returns object", () => { const s=engine.score(); if(typeof s.pct!=="number") throw new Error(); });
test("chainable addQuestion", () => { const e=new QuizEngine(); const r=e.addQuestion({type:"tf",question:"?",answer:1}); if(r!==e) throw new Error("not chainable"); });
console.log(`
${p} passed, ${f} failed`);

5. The Interface

node quiz-engine.mjs

=== JavaScript Quiz (3 questions) ===

Q: What does typeof null return?
  1. null   2. undefined   3. object   4. boolean
  Answer: 3
  ✓ Correct! (+2)

=== Results ===
Score: 5/5 (100%)

6. Run It

node quiz-engine.mjs
node quiz-engine.test.mjs

🎯 Try this next

  1. Load questions from a JSON file — makes the engine data-driven
  2. Add a leaderboard: save high scores with player name and timestamp to JSON
  3. Build a browser version — render questions as HTML, track score with React state
  4. Add question shuffling and option shuffling to prevent memorisation

What you practised

Class with private fields (#questions, #results), method chaining with return this, Promise.race for timed questions, the nullish assignment operator (??=), and category-based score aggregation.

Reference: OOP & Classes · Async & Promises · Set & Map

Try It Yourself