DEV Community

Niamh Doherty
Niamh Doherty

Posted on

A config-driven checkpoint quiz in vanilla JS, no build step

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>
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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]);
Enter fullscreen mode Exit fullscreen mode

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:

  1. Escape every string that came from config before it hits innerHTML.
  2. 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)