A real-time, embeddable quiz application designed for integration with any HTML-based presentation system (e.g., LectureDoc2). The core is a vanilla JavaScript Web Component (<ld-quiz>) that handles presenter authentication, quiz decryption, participant management, and real-time game flow.
- Web Component Architecture: Embeddable in any HTML document using Shadow DOM for style isolation
- Real-time Gameplay: Native WebSocket communication for live quiz sessions
- Secure Quiz Content: Client-side AES-GCM encryption with PBKDF2 key derivation (
@lecturedoc2/libcrypto) - Presenter Control: Separate control window with session management and reconnection support
- Participant QR Code Join: Participants scan a QR code or use a short link to join
- Math Formula Support: Server-based KaTeX rendering for quiz questions and answers
- Syntax-highlighted Code: Server-based highlight.js rendering of fenced code blocks in 14 languages
- Minimal Dependencies: Express.js and
ws(WebSocket) on the server; the crypto library - part of LectureDoc2 without additional transitive dependencies - is the client's only dependency
- Multiple Choice: Standard A/B/C selection
- Estimation: Numeric answers with rank-based scoring:
- 1st closest: 100 points
- 2nd closest: 50 points
- 3rd closest: 25 points
- Tie-breaker: earliest submission wins
This is a pnpm workspace with two packages: the embeddable web component and the backend that hosts a quiz session. ARCHITECTURE.md has the diagrams: which asset is served by which host, and how a round is played.
.
├── package.json # workspace root: build + dev scripts
├── pnpm-workspace.yaml
├── build.js # esbuild: bundles the browser code into dist/
├── Caddyfile, caddy/ # reverse proxy / static file server
│
├── packages/
│ ├── ld-quiz/ # the embeddable component (publishable to npm)
│ │ ├── src/ld-quiz.js # the <ld-quiz> custom element
│ │ ├── src/protocol.js # the wire contract; shared with the server
│ │ ├── src/qr-canvas.js # draws the join QR code onto a <canvas>
│ │ ├── src/quiz-styles.js # Shadow DOM styles
│ │ └── package.json # deps: @lecturedoc2/libcrypto, qrcode-generator
│ │
│ └── server/ # the backend and the pages it serves
│ ├── src/server.js # Express + WebSocket server
│ ├── src/rooms.js # room state management and scoring
│ ├── src/math-renderer.js # server-side KaTeX rendering
│ ├── src/code-renderer.js # server-side syntax highlighting
│ ├── src/name-validator.js # participant name validation
│ ├── src/network-addresses.js
│ ├── web/ # join/control/demo pages (HTML, CSS, page scripts)
│ ├── test/
│ └── package.json # deps: express, ws, katex, highlight.js
│
├── tools/encrypt-quiz.js # CLI utility to encrypt quiz JSON
├── tools/validate-quiz.js # CLI utility to check a quiz file
├── quizzes/ # example quizzes (plain and encrypted)
└── dist/ # build output served by Caddy (git-ignored)
The browser code imports the crypto library by its bare specifier
(@lecturedoc2/libcrypto), which browsers cannot resolve on their own.
build.js uses esbuild to bundle each browser entry point — inlining the library —
and assembles dist/ so that its layout matches the public URLs one-to-one:
| Source | URL |
|---|---|
packages/ld-quiz/src/ld-quiz.js |
/component/ld-quiz.js and /component/<version>/ld-quiz.js |
packages/server/web/js/quiz-client.js |
/js/quiz-client.js |
packages/server/web/js/quiz-tools.js |
/js/quiz-tools.js |
packages/server/web/js/quiz-control.js |
/js/quiz-control.js |
packages/server/web/*.html |
/*.html |
packages/server/web/styles/ |
/styles/ |
packages/server/web/katex/ |
/katex/ |
packages/server/web/highlight/ |
/highlight/ |
Consumers that use a bundler themselves can instead import the component's sources directly and let their own bundler resolve the dependency.
From the repository root — this installs the workspace and both packages at once:
pnpm installpnpm build # writes dist/
pnpm watch # rebuilds the browser code on change
pnpm build --minify # minified output (implied by NODE_ENV=production)dist/ is git-ignored and must be rebuilt after every checkout and every deployment.
Create a JSON file with your quiz questions:
{
"title": "Mathematics Quiz",
"questions": [
{
"type": "multiple-choice",
"text": "What is the derivative of \\( x^2 \\)?",
"options": [
"\\( 2x \\)",
"\\( x \\)",
"\\( x^2 \\)"
],
"correctIndices": [0],
"timeLimit": 30
},
{
"type": "estimation",
"text": "Estimate the value of \\( \\sqrt{2} \\) to 2 decimal places.",
"correctAnswer": 1.41,
"timeLimit": 45
}
]
}Either on the command line:
pnpm validate quizzes/my-quiz.json
QUIZ_PASSWORD='the quiz password' \
pnpm encrypt quizzes/my-quiz.json quizzes/my-quiz-encrypted.txt…or in a browser, at https://<host>/tools.html, which does the same two
things plus generating a secret. Both routes import the same three modules —
quiz-validator.js, quiz-secret-advice.js and quiz-encryption.js — so a
quiz gets the same verdict and the same cipher parameters either way; only the
front end differs. The page is a static file: nothing is uploaded, and the
password never leaves the tab. It needs HTTPS or localhost, because browsers
withhold crypto.subtle outside a secure context.
pnpm validate checks the quiz against the same rules the server applies —
it imports packages/server/src/quiz-validator.js rather than restating them —
so a quiz it accepts is a quiz that will start. It additionally warns when the
quiz has no secret field, which the server cannot require but an author almost
always wants: see PRESENTER-TOKEN.md. Add --strict to
turn those warnings into a non-zero exit for CI.
The password may also be given as a third argument, which is handier and leaves
it in the shell history and in ps output — fine for a throwaway, not for the
password that also gates the presenter token.
The Node backend exposes only the API and WebSocket endpoint. In production it should sit behind Caddy and not be reached directly from browsers.
pnpm start
# Backend listens on http://localhost:3000Caddy serves the contents of dist/ and proxies API/WebSocket traffic to the backend. Run it from the repository root:
# Local development with HTTPS (uses the mkcert certificate in the repo root)
CADDY_HOST=localhost TLS_CONFIG=caddy/tls-mkcert.txt caddy run --config Caddyfile
# Local-network development (e.g. for phones on the same Wi-Fi)
CADDY_HOST=192.168.178.161 TLS_CONFIG=caddy/tls-mkcert.txt caddy run --config Caddyfile
# Production (automatic HTTPS via Let's Encrypt / ZeroSSL)
CADDY_HOST=quiz.example.com caddy run --config CaddyfileOpen https://<host>/demo.html in your browser.
The <ld-quiz> component supports both encrypted and unencrypted quizzes.
Add the encrypted attribute and provide the encrypted quiz data:
<ld-quiz encrypted quiz="MTAwMDAw:abc123...encrypted-data..." server-url="https://quiz.example.com"></ld-quiz>
<script type="module" src="https://quiz.example.com/component/1.0.1/ld-quiz.js"></script>The presenter will be prompted for a password to decrypt the quiz.
Provide the quiz JSON directly via the quiz attribute:
<ld-quiz quiz='{"title":"My Quiz","questions":[...]}' server-url="https://quiz.example.com"></ld-quiz>The quiz title is shown with a "Start Quiz" button. No password is required.
If no quiz attribute is provided, a file picker is shown:
<ld-quiz server-url="https://quiz.example.com"></ld-quiz>The presenter selects a .json file, and the quiz title is shown with a "Start Quiz" button.
Note: The
server-urlattribute is required when the quiz server is hosted on a different domain than the slide set. If omitted, it defaults to the current page's origin.
When the slide server and quiz server are on different origins, the quiz server must allow cross-origin requests.
Caddy adds permissive CORS headers (Access-Control-Allow-Origin: *) to all static-file responses, so stylesheets and ES modules can be loaded from any origin.
For the API, CORS is handled by the Node backend. It allows all origins by default, but you can restrict it:
# Allow any origin (default, for development)
ALLOWED_ORIGINS="*" pnpm start
# Restrict to specific origins
ALLOWED_ORIGINS="https://slides.example.com,https://presenter.example.com" pnpm startThe Caddyfile in the repository root is configured through environment variables:
| Variable | Default | Description |
|---|---|---|
CADDY_HOST |
localhost |
Site address (e.g. localhost, 192.168.178.161, quiz.example.com) |
BACKEND_HOST |
localhost |
Host of the Node backend |
BACKEND_PORT |
3000 |
Port of the Node backend |
TLS_CONFIG |
caddy/tls-auto.txt |
Path to a TLS snippet file. Empty file = automatic HTTPS. Set to caddy/tls-mkcert.txt for local HTTPS. |
ALLOWED_ORIGINS |
* |
Origins allowed by the Node API CORS middleware |
See DEPLOY.md for the full walkthrough. In short:
- Caddy is the public entry point: it terminates TLS and serves
dist/. - API requests (
/api/*) and WebSocket connections (/ws) are proxied to the Node backend. - The backend runs on
localhost:3000(configurable viaBACKEND_HOST/BACKEND_PORT). - After every
git pull, runpnpm install && pnpm build—dist/is not committed.
TLS is required because the Web Crypto API (used for hashing and decryption) only works in a secure context.
- Open the page containing the quiz component
- Enter your password to decrypt and start the quiz
- The control window opens automatically
- Participants join by scanning the QR code or visiting the join URL
- Use the control window to start rounds, end rounds, and advance questions
{
"title": "Quiz Title",
"questions": [
{
"type": "multiple-choice",
"text": "Question text with optional \\( LaTeX \\) math",
"options": ["Option A", "Option B", "Option C"],
"correctIndices": [0],
"timeLimit": 30
},
{
"type": "estimation",
"text": "Question text",
"correctAnswer": 42.0,
"timeLimit": 45
}
]
}text: Question text. Rendered as HTML, and preprocessed on the server for KaTeX math (\(...\),\[...\],$...$,$$...$$) and for fenced code blocks — see Code blocks below.options: Array of strings (for multiple-choice)correctIndices: Array of zero-based indices of correct options (for multiple-choice). Single-answer questions use a one-element array, e.g.[0]. Multi-select questions use multiple indices, e.g.[0, 2].correctAnswer: Numeric value (for estimation)timeLimit: Optional countdown timer in seconds
Code in a question or an option is written as a Markdown fence, and highlighted on the server before anything is sent to a participant:
{
"type": "multiple-choice",
"text": "What does this print?\n```java\nSystem.out.println(1 / 2);\n```",
"options": ["0", "0.5", "It does not compile"],
"correctIndices": [0]
}Supported languages, by the name you write after the opening fence:
| Language | Write | Also accepted |
|---|---|---|
| Bash / shell | bash |
sh, shell, zsh, console |
| C | c |
h |
| C++ | cpp |
c++, cxx, cc, hpp |
| CSS | css |
|
| Go | go |
golang |
| HTML / XML | html |
xml, xhtml, svg |
| Java | java |
|
| JavaScript | javascript |
js, mjs, jsx, node |
| JSON | json |
jsonc |
| Markdown | markdown |
md |
| Python | python |
py, python3 |
| Rust | rust |
rs |
| TypeScript | typescript |
ts, tsx |
| YAML | yaml |
yml |
HTML and XML share one grammar; html is an alias of xml, not a separate
language.
Three rules are worth knowing before a lecture rather than during one:
- The opening fence must start a line. A ``` inside a sentence is a backtick, not the start of a code block.
- The language is never guessed. A fence with no language — or with
text/plain— is shown as unhighlighted code. An unrecognised language is also shown unhighlighted, and the presenter is told which one it was. Nothing is ever dropped. - A longer fence carries a shorter one, so a Markdown example containing a three-backtick block is written with four backticks.
Code is escaped before anything else looks at it, so a sample containing
<script>, an onclick= attribute or a $ is shown as written — the markup
safety net and the math renderer both leave code blocks alone. See
packages/server/src/code-renderer.js for the ordering and why it matters.
| Attribute | Required | Description |
|---|---|---|
encrypted |
No | Presence attribute. If set, the quiz value is treated as encrypted ciphertext |
quiz |
No | The quiz data: encrypted ciphertext (when encrypted is present) or inline JSON string. Omit it to get a file picker instead — see Unencrypted Quiz — File Upload. Required when encrypted is present, since there is nothing to decrypt otherwise |
presenter-name |
No | Prefills the presenter name field. See below |
server-url |
No | The quiz server URL. Defaults to window.location.origin |
The presenter name is an isolation key, not a secret: it is what separates two
lecturers running the same quiz file, and reproducing it exactly is what lets a
presenter reattach to their own running session after a crash or a reload.
Typing it slightly differently — Michael one week, michael the next — is
therefore the failure this attribute exists to prevent. Since a slide set is
nearly always presented by the person who wrote it, the slide set is the right
place to state the name:
<ld-quiz encrypted quiz="..." presenter-name="Michael Eichberg"></ld-quiz>It only prefills the field; the presenter can still change it before starting.
The attribute takes precedence over the name remembered in localStorage,
because it is a deliberate statement about this deck whereas the remembered
value is whatever the last person at this lectern happened to type.
There are two ways to consume the component, and they exist because the two kinds of consumer are opposites. (Releasing a new version of the npm package is its own runbook: see PUBLISH.md.)
Use this when the embedding project runs a bundler of its own — LectureDoc2, or anything else that can resolve a bare specifier.
pnpm add @lecturedoc2/quizzyimport "@lecturedoc2/quizzy"; // registers <ld-quiz>
// or, to control the tag name:
import { defineLdQuiz } from "@lecturedoc2/quizzy";
defineLdQuiz("my-quiz");No loader configuration is required. The component's stylesheet is authored as a
plain JavaScript module (src/quiz-styles.js), so no CSS import plugin, import
attribute, or asset pipeline has to be set up by the consumer — and no silent
undefined when one is forgotten.
Use this when the embedding page is produced by something that cannot run a bundler — a Moodle plugin, a CMS, a hand-written HTML page.
<ld-quiz quiz='{"title":"Demo","questions":[]}'
server-url="https://quiz.example.com"></ld-quiz>
<script type="module"
src="https://quiz.example.com/component/1.0.1/ld-quiz.js"></script>Prefer the versioned URL. /component/<version>/ld-quiz.js is immutable: it is
served with a one-year immutable cache header and its bytes never change, so it
can be pinned with Subresource Integrity and will still mean the same thing next
term. /component/ld-quiz.js tracks whatever the quiz server was last deployed
with, which is convenient for the pages served alongside it and a poor promise to
make to anyone else.
/component/version.json reports the version currently deployed.
<ld-quiz> is registered globally, and customElements.define throws on a name
that is already taken — a throw that aborts the module doing the defining. A page
that combines both integration styles (a LectureDoc2 deck, itself bundling the
component, embedded in a Moodle course that loads the hosted script) would do
exactly that. Registration is therefore guarded: the second copy yields with a
console warning instead of taking the page down. Call defineLdQuiz("other-tag")
if both copies genuinely need to be live.
| Requirement | Why |
|---|---|
A secure context (https, or localhost) |
Web Crypto is used for hashing and decryption |
script-src allowing the quiz host |
the module is loaded from there |
style-src allowing the quiz host |
the KaTeX and highlighting stylesheets are linked into the shadow root |
connect-src allowing wss://<quiz-host> |
the game runs over a WebSocket |
img-src data: |
the join QR code is drawn to a canvas |
Note what is not on that list: style-src 'unsafe-inline'. The component's
own styles are applied as a constructed stylesheet (adoptedStyleSheets), which
is a JavaScript object rather than markup and therefore outside the reach of
style-src. A host with a strict policy — which a Moodle site usually has —
needs no concession for it. Browsers without constructed stylesheets fall back to
a <style> element, and there the old requirement returns.
KaTeX is linked rather than inlined for a related reason: its @font-face rules
use relative URLs, so a copy of the text would resolve the fonts against the
embedding page instead of the quiz server, silently replacing every formula with
a fallback font in precisely the cross-origin setup this component is for.
The component asks the server where its own endpoints are, once per origin:
GET /api/config
{
"protocolVersion": 1,
"serverVersion": "1.0.0",
"maxQuizSize": 1048576,
"ws": "/ws",
"join": "/join.html",
"control": "/control.html",
"katexCss": "/katex/katex.min.css",
"highlightCss": "/highlight/highlight.min.css"
}Paths are relative to the URL used to reach the endpoint, so nothing has to know
the public origin — which, behind a reverse proxy, it generally does not. Each
can be overridden with an environment variable (PUBLIC_WS_PATH,
PUBLIC_JOIN_PATH, PUBLIC_CONTROL_PATH, PUBLIC_KATEX_CSS_PATH,
PUBLIC_HIGHLIGHT_CSS_PATH) or with absolute URLs, which is what lets the quiz
server live under a path prefix rather than owning a domain root.
Every key listed above is required. A component that does not find one of them refuses to start rather than falling back to a hardcoded layout: a silently wrong path is the failure mode this endpoint exists to remove.
There is deliberately no fallback to a hardcoded layout: a server that cannot answer this is a server the component cannot talk to, and saying so immediately beats guessing an endpoint and failing later with a broken WebSocket.
The component and the server ship on different schedules: a slide deck pins a
bundled copy and is re-published when its author feels like it, and an
institutional quiz server is updated when its administrator feels like it.
PROTOCOL_VERSION (see packages/ld-quiz/src/protocol.js) is what keeps a stale
pairing from failing obscurely.
- Clients send
protocolVersiononcreate_room,join_room,control_connectandroom_status. - A server that does not speak the announced version replies with
{ "type": "error", "code": "protocol_mismatch", "message": ... }naming both versions, and closes the socket. It does not count against the abuse limits: a stale deployment is not a prober. - A missing
protocolVersionis a mismatch like any other. Defaulting it away would only mean that the first real incompatibility surfaces as a confusing runtime failure instead of as this error.
The version describes the wire format, not the release, and is deliberately decoupled from the package version so that a UI-only release does not invalidate every embedded copy in the wild.
Nothing Moodle-specific is required: a filter or editor plugin that emits the
<ld-quiz> element and the hosted <script type="module"> above is enough, plus
the CSP directives listed earlier. Results stay in the quiz session — there is no
LTI integration and no grade passback, and the server persists nothing.
- The quiz JSON is encrypted on the client side using the presenter's password
- The server receives the decrypted quiz only after the presenter decrypts it
- The server never persists quiz data to disk; all data is ephemeral (in-memory only)
- The presenter uses a SHA-256 hash of the quiz to identify himself.
- Participants require no authentication; they join via a random room code
- Cross-origin: The quiz server allows CORS from any origin by default. For production, restrict with
ALLOWED_ORIGINS
| Method | Path | Purpose |
|---|---|---|
GET |
/api/config |
Endpoint discovery and protocol version. See Service discovery. |
There is deliberately no endpoint that lists rooms or sessions: the application has no administrator role, and a room can only be reached by knowing its id.
All messages are JSON objects with a type field.
Messages that open a conversation (create_room, join_room, control_connect,
room_status) also carry protocolVersion; see
Protocol versioning.
Presenter → Server:
create_room: Create a new quiz roomprotocolVersion: The wire-format version the client speakspresenterToken: SHA-256 hash of the quiz data or passwordquiz: The quiz object (rendered server-side)
start_game: Begin the quizend_question: End the current question and show resultsnext_question: Advance to the next question or end the gamecontrol_connect: Connect to a room as presenter- With
roomId: join the control channel for that room - Without
roomId: receive asessions_listof all rooms for this presenter
- With
Participant → Server:
join_room: Join a room with a nameroomId: Room to joinname: Participant display name
submit_answer: Submit an answeranswer: Selected option index, array of indices, or estimation value
Server → Presenter (ld-quiz component):
room_created: Room was created; includesroomId,quizTitle,totalQuestionsparticipant_joined/participant_left: Lobby participant count updates
Server → Control Window (quiz-control.js):
control_connected: Initial state after connecting; includesroomId,state,quizTitle,totalQuestions,currentQuestionIndex,question,participantCount,leaderboardsessions_list: List of active sessions when noroomIdwas providedgame_started: Quiz started; includesquestionIndexandquestionquestion_started: Next question is active; includesquestionIndexandquestionquestion_results: Question ended; includesquestionIndex,answers, andleaderboardanswer_count: Live count of submitted answers during a questionparticipant_joined/participant_left: Participant count updates
Server → Participant (quiz-client.js):
joined: Successfully joined; includesparticipantIdandquizTitlequestion: New question available; includesquestionIndex,totalQuestions,question,startTimeresults: Round results withleaderboard,questionIndex, andwaitinggame_ended: Final results; includesleaderboard
Server → Broadcast:
game_ended: Sent to all connected clients when the quiz ends
Server → Any Client:
error: Error message from the server
The server package uses Node.js's built-in test runner:
pnpm test # from the repository root
pnpm --filter ld-quiz-server test:watchTests cover:
- Room state management: Participant lifecycle, game flow, scoring logic
- Math rendering, syntax highlighting and the order the two run in
- Name validation and network address discovery
- Server integration: CORS headers, HTTP API, WebSocket message flow
- The integration contract:
/api/configand the protocol handshake
pnpm test:browser # Playwright, needs a browser (see below)
pnpm test:all # both suitesThese need Chromium, which a fresh checkout does not have: pnpm-workspace.yaml
blocks Playwright's postinstall so that deploying the server never drags several
hundred megabytes of browser binaries onto a production host. Install it once,
explicitly:
pnpm --filter ld-quiz-server exec playwright install chromiumOr point the tests at a browser that is already on the machine:
PLAYWRIGHT_CHROMIUM_PATH=/path/to/chrome pnpm test:browserCrypto is no longer tested here; it lives in and is tested by
@lecturedoc2/libcrypto.
# Terminal 1
pnpm build && pnpm start
# Terminal 2 (repo root)
CADDY_HOST=localhost TLS_CONFIG=caddy/tls-mkcert.txt caddy run --config Caddyfile
# Open https://localhost/demo.html
# Use password: test123Go to, e.g.,: https://192.168.178.161/demo.html to open the application.
Math formulas and code blocks are both rendered to HTML on the server, before anything is sent to a client. Neither KaTeX nor highlight.js is shipped to a participant's browser; each needs only a stylesheet to display the result.
When using the ld-quiz web component, both stylesheets are fetched
automatically from the configured server-url and linked into the shadow DOM, so
no manual setup is required.
For standalone pages (such as demo.html), include them in the host page:
<link rel="stylesheet" href="/katex/katex.min.css">
<link rel="stylesheet" href="/highlight/highlight.min.css">Both are vendored under packages/server/web/ and copied to dist/app/ by the
build. The highlighting theme is highlight.js's 1c-light, kept under a
theme-neutral filename on purpose: /api/config publishes it as highlightCss,
so a deployment swaps the theme by replacing that one file (or by pointing
PUBLIC_HIGHLIGHT_CSS_PATH elsewhere) without touching the component, the
<link> tags, or any embedder's cached configuration.
A dark theme is deliberately not shipped yet.
This application targets modern browsers (released within the last year). It uses:
- Web Components (Custom Elements, Shadow DOM)
- ES Modules
- Web Crypto API
- Native WebSocket
- CSS Custom Properties
BSD-3-Clause (same as the underlying crypto library and LectureDoc2)