Most product-training pages are either a CMS quiz plugin or a React app that needs a bundler. I wanted neither. The constraint was: drop two files on a static host, pass a config object, get a playable road-trip quiz.
The result is a small engine called RoadQuest. The interesting part is not the truck animation. It is the split between state and content.
The only API
Mount a node, pass data, stop.
<link rel="stylesheet" href="roadquest.css">
<div id="quest"></div>
<script src="roadquest.js"></script>
<script>
RoadQuest.init('#quest', {
brand: { name: 'Acme Training', tagline: 'Checkpoint quiz' },
hero: {
title: 'Training Quest',
description: 'Answer each question to complete the journey.'
},
checkpoints: [
{
topic: 'Safety',
question: 'Which answer is correct?',
options: ['First answer', 'Second answer', 'Third answer'],
correctIndex: 1,
explanation: 'The second answer is correct.'
}
]
});
</script>
init accepts a selector or a DOM node. There is no router, no JSX, no npm install. If checkpoints is missing or empty, the engine throws before it paints anything.
Content is data, the engine is a state machine
Each checkpoint is a record: question, options, correctIndex, optional topic / badge / explanation. The engine never knows what the quiz is about. That is the point.
Internally the game only has a handful of fields:
this.level = 0;
this.score = 0;
this.streak = 0;
this.firstTry = 0;
this.missedThisLevel = false;
Screens are start → quiz → drive animation → next checkpoint → finish. Wrong answers do not advance level. They mark missedThisLevel, reset the streak, and let the player retry immediately. A checkpoint still counts as cleared; first-try accuracy is what drops.
Scoring is deliberately cheap to explain:
if (i === q.correctIndex) {
if (!this.missedThisLevel) this.firstTry++;
this.streak++;
this.score += 100 + Math.min((this.streak - 1) * 20, 80);
} else {
this.missedThisLevel = true;
this.streak = 0;
this.score = Math.max(0, this.score - 25);
}
On finish, onComplete receives { score, accuracy }. You can log it, post it, or ignore it. The engine does not own analytics.
Theme without a design system
Colours are CSS custom properties written onto the mount node:
root.style.setProperty('--rq-' + key, merged[key]);
A second brand is a different config, not a fork. The same roadquest.js + roadquest.css pair runs a fictional barista onboarding quiz and a hardware training quiz. Copy the config file; leave the engine alone.
Why this shape works on a product site
The first production config is a nine-checkpoint training game I wrote for TruckNav. The questions cover setup and vehicle-profile basics a driver should confirm while parked. The engine source is on GitHub.
That config is still just data: route labels, a stageLength of 100 km, theme colours, and a cta href. Swap the checkpoints and the same engine becomes an onboarding quiz for a different team.
Two rules stayed non-negotiable and are worth copying if you build anything in this genre:
- Escape every string that came from config before it hits
innerHTML. - The quiz is training, not the product. Road signs and legal restrictions still win.
What I would change next
The engine is one IIFE on window. That is the right size for a GitHub Pages demo. If I were embedding it in a larger app I would export the class, keep innerHTML for the shell, and render answers with createElement only. The config schema would not need to change.
If you want a quiz on a static site and you do not want a framework for it, keep the questions in a data file and keep the game in about 300 lines of JavaScript. The brand should be a config object, not a rewrite.
Top comments (0)