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
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
- Load questions from a JSON file — makes the engine data-driven
- Add a leaderboard: save high scores with player name and timestamp to JSON
- Build a browser version — render questions as HTML, track score with React state
- 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