25 KiB
Live In-Lecture Quiz Portal — Implementation Spec
Project: quiz (live-paced classroom engagement quiz, Kahoot-style)
Build location: /home/ameer/RD/Projects/Apps/quiz/
Public domain (planned): quiz.ahkhan.me
Owner: Prof. Ameer H. Khan, Taizhou University
Spec version: 1.0 (2026-05-01)
Stack (locked): Python 3.11+, FastAPI, websockets (via FastAPI's WebSocket support), aiosqlite, vanilla HTML+CSS+JS frontend (no framework, no build step).
This is a complete, build-ready specification. Implement exactly what is here. Do not invent new features. Where the spec is silent, choose the simplest reasonable option and document it briefly in code comments or in NOTES.md.
1. High-level flow
A teacher starts a quiz session. Students scan a QR code or open a URL like https://quiz.ahkhan.me/?sid=ABC123. They identify themselves once with student ID + name (saved as a signed cookie). They wait in a lobby. The teacher opens questions one at a time from an admin dashboard. Students answer within a configurable time window (default 60s); earlier correct answers score more points (linear time decay). After each question, the teacher reveals the answer and a top-5 leaderboard. At the end, the teacher downloads a CSV of all results.
This is a participation/engagement tool, not a summative assessment. Anti-cheating is intentionally minimal (a single signed cookie discourages but does not prevent proxy answering); the in-lecture paper attendance sheet remains the load-bearing presence check.
2. Personas
- Instructor (1 per session): authenticates with a shared admin password (env-configured). Creates sessions, opens/closes questions, sees all stats, downloads CSV.
- Student (N per session): identifies with student ID + name, persists via cookie, answers questions when revealed, sees own rank + top 5.
3. URL routes
Student-facing
| Path | Method | Purpose |
|---|---|---|
/?sid=<code> |
GET | Student entry. If sid missing/invalid → "Ask your instructor for the link" page. Otherwise: serve student SPA. |
/api/session/<sid> |
GET | Public session metadata: {title, state, current_question_idx, time_limit_default} (no quiz content). |
/api/session/<sid>/join |
POST | Body: {student_id, name}. Sets signed cookie, creates participants row (or updates name if student_id already present). Returns {ok, cookie_id}. |
/api/session/<sid>/me |
GET | Cookie-authenticated. Returns {student_id, name, total_score, submissions: [...]} for the current student. |
/api/session/<sid>/stats |
GET | Public stats for end-of-question display: {question_idx, response_time_avg_ms, response_time_distribution, average_score, top5: [{rank, name, score}], your_rank?}. Cookie-aware for your_rank. |
/ws/student/<sid> |
WebSocket | Cookie-authenticated. Per-client connection for state updates and submissions. |
Instructor-facing
| Path | Method | Purpose |
|---|---|---|
/admin/login |
GET / POST | Login form. POST: body {password}. On success sets admin signed cookie. |
/admin/ |
GET | Admin dashboard SPA (requires admin cookie). |
/admin/api/quizzes |
GET / POST | List quiz pools / create new (POST: body {title, pool_json, time_limit_default}). |
/admin/api/quizzes/upload |
POST | Multipart upload of a pool JSON file (alternative to direct POST). |
/admin/api/sessions |
GET / POST | List sessions / create new (POST: body {quiz_id} returns {sid, qr_url, join_url}). |
/admin/api/sessions/<sid>/csv |
GET | Download final results CSV. |
/ws/instructor/<sid> |
WebSocket | Admin-cookie-authenticated. Sends control commands, receives all real-time events. |
Other
| Path | Method | Purpose |
|---|---|---|
/healthz |
GET | Returns {ok: true, version, sessions_active, ws_clients}. |
/static/* |
GET | Static frontend assets (student.html, admin.html, quiz.js, admin.js, style.css, etc.). |
4. Data model (SQLite, via aiosqlite)
Tables:
CREATE TABLE quizzes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
pool_json TEXT NOT NULL, -- the question pool, see §6
time_limit_default INTEGER NOT NULL DEFAULT 60, -- seconds
score_fn_name TEXT NOT NULL DEFAULT 'linear_decay',
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE quiz_sessions (
sid TEXT PRIMARY KEY, -- 6-char Crockford base32, see §10
quiz_id INTEGER NOT NULL REFERENCES quizzes(id),
title TEXT NOT NULL, -- snapshot of quiz title at session-create
state TEXT NOT NULL DEFAULT 'lobby', -- 'lobby'|'question_open'|'question_closed'|'between_questions'|'finished'
current_question_idx INTEGER, -- NULL when state='lobby' or 'finished'
started_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
finished_at TIMESTAMP
);
CREATE TABLE participants (
sid TEXT NOT NULL REFERENCES quiz_sessions(sid),
student_id TEXT NOT NULL,
name TEXT NOT NULL,
cookie_id TEXT NOT NULL, -- random uuid stored in cookie too
joined_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (sid, student_id)
);
CREATE TABLE question_events (
sid TEXT NOT NULL REFERENCES quiz_sessions(sid),
question_idx INTEGER NOT NULL,
opened_at TIMESTAMP NOT NULL,
closed_at TIMESTAMP,
time_limit INTEGER NOT NULL, -- seconds, snapshot at open
PRIMARY KEY (sid, question_idx)
);
CREATE TABLE submissions (
sid TEXT NOT NULL REFERENCES quiz_sessions(sid),
student_id TEXT NOT NULL,
question_idx INTEGER NOT NULL,
answer TEXT, -- option key 'A'|'B'|'C'|'D' or NULL if missed
submitted_at TIMESTAMP, -- NULL if missed
elapsed_ms INTEGER, -- NULL if missed; else server-side measured from opened_at
score INTEGER NOT NULL DEFAULT 0,
status TEXT NOT NULL DEFAULT 'submitted', -- 'submitted'|'missed'|'late_join'
PRIMARY KEY (sid, student_id, question_idx)
);
CREATE INDEX idx_submissions_sid_qidx ON submissions(sid, question_idx);
CREATE INDEX idx_participants_sid ON participants(sid);
Use WAL mode (PRAGMA journal_mode=WAL) for concurrent read-during-write.
5. State machine
A quiz_sessions row moves through these states:
lobby ──open_question──> question_open ──close_question──> question_closed
│
┌────────next (if more Qs)──────┘
▼
between_questions ──open_question──> question_open
│
┌─end_session─────┘
▼
finished
lobby: students can join, no question visible. Can transition toquestion_open(open Q0).question_open: question visible, accepting submissions. Server auto-transitions toquestion_closedwhentime_limitexpires; instructor can also force-close early viaclose_question.question_closed: correct answer + histogram + leaderboard sent to all clients. Instructor advances vianext→between_questions.between_questions: brief intermission, dashboard shows leaderboard. Instructor opens next Q viaopen_question.finished: session over. Final leaderboard pushed. CSV downloadable from admin.
Auto-close behavior: server schedules an asyncio task at open_question time that fires after time_limit seconds and triggers close_question if state is still question_open for that question.
Idempotency: open_question(idx) while already in question_open for the same idx is a no-op. Opening a different idx while question_open first auto-closes the current one.
6. Question pool JSON format
A quiz pool is a JSON document like:
{
"title": "Week 9 Recap — Computer Organization",
"score_fn": "linear_decay",
"time_limit_default": 60,
"questions": [
{
"id": "q1",
"text": "Which signal triggers the writeback to the register file?",
"options": {
"A": "MARWrite",
"B": "RegWrite",
"C": "MemRead",
"D": "PCsrc"
},
"correct": "B",
"time_limit": 60,
"explanation": "RegWrite gates the W-phase writeback into the RF."
},
{
"id": "q2",
"text": "...",
"options": { "A": "...", "B": "...", "C": "...", "D": "..." },
"correct": "C"
}
]
}
Field rules:
score_fn(optional, defaultlinear_decay): name of the scoring function inapp/scoring.pyregistry.time_limit_default(optional, default 60): per-quiz default. Per-questiontime_limitoverrides.questions[].time_limit(optional): per-question override.questions[].explanation(optional): shown to students after the reveal.- Options must always be exactly the 4 keys
A,B,C,D. Correct must be one of those.
Validation: validate at quiz-create time; reject pool JSON that violates these rules with a clear error.
7. Scoring (modular)
Implement app/scoring.py with a registry pattern:
SCORE_FNS: dict[str, Callable[[bool, int, int], int]] = {}
def register(name): ...
@register("linear_decay")
def linear_decay(correct: bool, elapsed_ms: int, time_limit_ms: int) -> int:
if not correct:
return 0
elapsed_ms = max(0, min(elapsed_ms, time_limit_ms))
return round(1000 * (1 - 0.5 * elapsed_ms / time_limit_ms))
@register("flat")
def flat(correct: bool, elapsed_ms: int, time_limit_ms: int) -> int:
return 1000 if correct else 0
@register("exponential_decay")
def exponential_decay(correct: bool, elapsed_ms: int, time_limit_ms: int) -> int:
if not correct:
return 0
import math
decay = math.exp(-2 * elapsed_ms / time_limit_ms)
return round(1000 * (0.5 + 0.5 * decay))
Scoring is invoked server-side at submission time using the server-measured elapsed_ms = (now - question_events.opened_at). For a late joiner who joins mid-question, elapsed_ms is still measured from the question's opened_at (NOT join time) so they are scored fairly within the same time window — this matches the user's requirement that late joiners get 0 unless they answer within the original window.
Important: The previous discussion considered "decay starts from join time" for late joiners; the user's final clarification is late/missed = 0, so we treat late joiners exactly the same as anyone else: if they submit before the window closes they're scored normally by elapsed-since-opened, otherwise 0.
If they connect after the question already auto-closed, their submissions row is created with status='missed', score=0 for that question.
8. WebSocket protocol
All messages are JSON objects with a type field. Use camelCase for field names in JSON only if it conflicts with Python conventions; prefer snake_case throughout for consistency.
Client → Server (student)
{ "type": "submit", "question_idx": 3, "answer": "B" }
{ "type": "ping" }
Client → Server (instructor)
{ "type": "open_question", "question_idx": 0, "time_limit": 60 }
{ "type": "close_question" }
{ "type": "next" }
{ "type": "end_session" }
{ "type": "ping" }
Server → Student
{ "type": "state", "state": "lobby", "current_question_idx": null, "title": "..." }
{ "type": "question_open",
"question_idx": 3,
"text": "...",
"options": {"A": "...", "B": "...", "C": "...", "D": "..."},
"time_limit": 60,
"opened_at_server_ts": 1714589123456,
"remaining_ms": 58200 }
{ "type": "submit_ack",
"question_idx": 3,
"score": 830,
"elapsed_ms": 4200 }
{ "type": "question_closed",
"question_idx": 3,
"correct": "B",
"explanation": "...",
"your_answer": "B",
"your_score": 830,
"histogram": {"A": 5, "B": 18, "C": 3, "D": 1, "missed": 2},
"top5": [{"rank":1,"name":"...","score":4520}, ...],
"your_rank": 7,
"your_total": 2940 }
{ "type": "between_questions",
"next_idx": 4,
"top5": [...],
"your_rank": 7,
"your_total": 2940 }
{ "type": "session_ended",
"final_top5": [...],
"your_rank": 7,
"your_total": 2940,
"questions_answered": 8,
"questions_correct": 6 }
{ "type": "error", "code": "...", "message": "..." }
{ "type": "pong" }
Server → Instructor
Everything the student gets, plus:
{ "type": "lobby_update",
"participants": [{"student_id":"...","name":"...","joined_at":"..."}],
"count": 24 }
{ "type": "live_histogram", // pushed periodically while question_open
"question_idx": 3,
"histogram": {"A":5,"B":12,"C":2,"D":0,"missed":0,"pending":5},
"submitted_count": 19,
"total_count": 24 }
{ "type": "full_leaderboard", // sent on between_questions and session_ended
"leaderboard": [{"rank":1,"student_id":"...","name":"...","score":...}, ...] }
Reconnection
On WS connect, server immediately sends a state message with the current session state. If state == question_open, it follows with a question_open message including correct remaining_ms so a reconnecting client sees the right countdown.
If a student reconnects and they have already submitted for the active question, the state follow-up includes a submit_ack echoing their stored answer + score.
9. Frontend pages
Three SPAs (each = single HTML + small JS module). No framework. No build step. Use ES modules served as-is.
/static/student.html (served at /)
- Reads
?sid=from URL. - If no
sidor invalid: shows "Ask your instructor for the link" page with a friendly message. - Calls
GET /api/session/<sid>to fetch metadata. - Checks for cookie. If absent or
sidmismatch: shows ID+name form. On submit:POST /api/session/<sid>/join, sets cookie via response. - After cookie present: opens
/ws/student/<sid>, renders state-dependent view:- Lobby: "You're in. Waiting for instructor to start." + your name + spinner.
- Question: question text, 4 buttons (large, mobile-friendly), countdown bar driven by
remaining_msand local clock (resync on every server message). Disable buttons after submit. - Submitted (still open): "✓ Submitted in 4.2s — +830 pts. Wait for the reveal."
- Question closed: show correct answer (highlighted green), your answer (highlighted red if wrong), explanation if present, top-5 leaderboard, your rank.
- Between questions: "Next question coming up. Your rank: 7/24."
- Finished: confetti or similar celebratory framing, final top-5, your rank, summary stats.
- Mobile-first responsive layout. Big tap targets. Dark mode okay.
/static/admin.html (served at /admin/)
- Login gate (POST
/admin/login). - Sidebar: list of quizzes, list of recent sessions.
- "Create session" button → modal: pick a quiz → returns
sid, displays large QR code + join URL on screen. - Active session view (one open at a time):
- Top: session title, state, connected count.
- Lobby tab: live roster.
- Per-question controls: prev | current Q (text + options) | next. Big
[Open]/[Close & Reveal]/[Next]buttons. - Live histogram chart (bar chart of A/B/C/D/missed counts) updates in real time during
question_open. - After close: show full leaderboard, correct-answer indicator, "Next" to advance.
- End-session button.
- "Download CSV" button always visible.
- Generate QR codes client-side (use a tiny vendored QR library, e.g.
qrcode-svg~3KB or a hand-rolled QR encoder). Or generate server-side as SVG via Python'sqrcode[svg]. Pick whichever is simpler; document choice.
/static/observer.html (optional, served at /observe/?sid=...)
Skip if time-pressed. Minimal cookieless view useful to project on a classroom screen: shows current Q + live histogram + leaderboard. Read-only, no submit. Lower priority than the two above.
10. Identifiers, secrets, cookies
Session ID (sid): 6-char Crockford base32 (alphabet 0123456789ABCDEFGHJKMNPQRSTVWXYZ, no I/L/O/U), generated cryptographically random. Display uppercase. ~10⁹ space, ample for collision avoidance at our scale; on collision, regenerate (max 5 retries).
Student cookie: name qz_student. Value = signed JSON {sid, student_id, name, cookie_id}. Use itsdangerous.URLSafeSerializer with SECRET_KEY env var. Cookie attributes: HttpOnly, SameSite=Lax, Secure (when behind HTTPS), Path=/, Max-Age=31536000 (1 year).
Admin cookie: name qz_admin. Value = signed {is_admin: true, ts: ...}. Same security attributes. Max-Age=86400 (1 day).
Admin password: env var QUIZ_ADMIN_PASSWORD. Reject login if env var unset (don't allow unauthenticated admin).
Single-cookie design (per spec lock): the cookie holds everything for one identity. Clearing the cookie loses all in-progress state on the client; server still has the participant row. If a cleared-cookie student rejoins with a new student_id, they get a fresh participant row — duplicate participation, but server has both records. We do NOT actively block this (warn-only at most). The friction of clearing the cookie + re-entering data is the soft anti-proxy deterrent.
11. Configuration (env vars)
| Var | Default | Purpose |
|---|---|---|
QUIZ_DB_PATH |
./quiz.db |
SQLite file path |
QUIZ_SECRET_KEY |
(required) | Cookie signing key |
QUIZ_ADMIN_PASSWORD |
(required) | Admin login password |
QUIZ_HOST |
127.0.0.1 |
uvicorn bind host |
QUIZ_PORT |
8001 |
uvicorn bind port |
QUIZ_PUBLIC_URL |
https://quiz.ahkhan.me |
Used to construct join URLs and QR codes |
QUIZ_LOG_LEVEL |
INFO |
Logging level |
Provide a .env.example file listing all of these with comments. Never commit real values.
12. Project layout
quiz/
├── README.md # how to run, dev workflow
├── SPEC.md # this file (already exists)
├── NOTES.md # implementation choices, edge cases, deviations
├── pyproject.toml # FastAPI, uvicorn, websockets, aiosqlite, itsdangerous, python-multipart, qrcode, pytest, pytest-asyncio, httpx, etc.
├── .env.example
├── .gitignore # quiz.db, .venv, __pycache__, .env
├── app/
│ ├── __init__.py
│ ├── main.py # FastAPI app factory, route registration
│ ├── config.py # env var loading
│ ├── db.py # aiosqlite connection, schema migrations
│ ├── auth.py # cookie signing, admin login
│ ├── models.py # pydantic models for API requests/responses
│ ├── pool.py # quiz pool JSON validation
│ ├── scoring.py # score function registry
│ ├── room.py # room manager (in-process state, WS broadcast, autoclose tasks)
│ ├── routes_student.py # student API + WS
│ ├── routes_admin.py # admin API + WS
│ └── csv_export.py # CSV download formatter
├── static/
│ ├── student.html
│ ├── admin.html
│ ├── observer.html # optional
│ ├── quiz.js
│ ├── admin.js
│ ├── style.css
│ └── vendor/ # any tiny vendored libs (e.g., QR encoder)
├── tests/
│ ├── conftest.py
│ ├── test_pool.py
│ ├── test_scoring.py
│ ├── test_auth.py
│ ├── test_api_student.py
│ ├── test_api_admin.py
│ ├── test_ws_student.py
│ ├── test_ws_admin.py
│ ├── test_state_machine.py
│ ├── test_late_join.py
│ ├── test_reconnect.py
│ ├── test_csv_export.py
│ └── test_load_simulation.py # see §14
└── examples/
└── week9_pool.json # sample pool covering W9 recap topics
13. Dev workflow
cd /home/ameer/RD/Projects/Apps/quiz
python3.11 -m venv .venv
source .venv/bin/activate
pip install -e '.[dev]'
cp .env.example .env
# edit .env with QUIZ_SECRET_KEY (random) + QUIZ_ADMIN_PASSWORD
pytest # all tests must pass
uvicorn app.main:app --reload # local dev server on :8001
Open http://127.0.0.1:8001/admin/, log in with admin password, create a session from the example pool, get the sid. Open http://127.0.0.1:8001/?sid=<that> in another browser/incognito to test as student.
14. Required tests
All tests use pytest + pytest-asyncio + FastAPI's TestClient (sync) and httpx.AsyncClient (async, for WS). Use a fresh in-memory or temp-file SQLite per test.
Unit tests
test_pool.py: pool JSON validation accepts well-formed pools, rejects: missing fields, wrong option keys, invalidcorrect, empty questions list. Hits allpool.pyvalidation paths.test_scoring.py: each registered score_fn produces correct values for: correct+early, correct+late, correct+after-window, wrong, edge cases (elapsed_ms=0, elapsed_ms=time_limit_ms, elapsed_ms>time_limit_ms).test_auth.py: cookie signing roundtrip, tampered cookie rejected, admin login success/failure paths.
API tests
test_api_student.py:/api/session/<sid>returns expected metadata; join endpoint creates participant + sets cookie; idempotent re-join with same student_id updates name; me/stats endpoints return expected shape.test_api_admin.py: login required for all/admin/*; quiz CRUD; session create returns sid+QR; CSV export shape correct.
WebSocket tests
test_ws_student.py: connect with valid cookie succeeds; without cookie rejected (4001 close); receives initial state message;submitfor open question returnssubmit_ackwith correct score;submitfor closed question rejected.test_ws_admin.py: requires admin cookie; control commands transition state correctly; broadcasts reach connected students.test_state_machine.py: full lifecycle test — create session, join 3 students, open Q0, all submit, close, advance, ..., end. Assert state transitions and DB rows.
Edge-case tests
test_late_join.py: student joins after Q0 already opened — getsstate+question_openwith reducedremaining_ms. Submits in time → scored correctly. Joins after Q0 closed → no submission for Q0, status='missed' on later finalization.test_reconnect.py: student submits, disconnects, reconnects — server resends current state including their existingsubmit_ackfor the active Q.
Load/simulation test (test_load_simulation.py)
- Spawn 50 simulated student WS clients in asyncio tasks (acceptance threshold; 100 if it runs in <30s).
- Run a full quiz with 5 questions; each simulated student answers within a randomized 1-50s window.
- Assert: no dropped messages, all submissions persisted with correct scores, final leaderboard CSV row count matches participant count, autoclose fires within 1s of
time_limit. - This is the single most important test — it validates real-world behavior.
Acceptance criteria
- All tests pass with
pytest -q. pytest --cov=app≥ 80% line coverage onapp/.- Manual smoke test (documented in
README.md): create example session, join as student in incognito browser, submit answer, see reveal — works end-to-end without errors in browser console or server log.
15. Operational notes (for later, not for codex implementation)
These are documented for human deployment and should NOT be implemented by codex (no systemd, no Caddyfile, no DNS work needed in this scope).
- Hosting target: mainland-China VPS (Alibaba Cloud Zhejiang) — to be provisioned by user separately.
- Reverse proxy: Caddy with auto-TLS via Let's Encrypt HTTP-01 on the host.
- Service supervision: systemd unit (template can be added later).
- Backup: nightly
sqlite3 .backupto a second file; this is out of scope for v1.
16. What NOT to build
- No user registration / OAuth. Identity is just student_id + name + cookie.
- No live chat or Q&A.
- No file uploads from students.
- No multi-language UI (English only for v1; Chinese can be added later).
- No analytics dashboards beyond per-question stats.
- No mobile native app.
- No PDF report generation.
- No email notifications.
- No persistent leaderboard across sessions.
17. Things explicitly OK to defer to NOTES.md
If during implementation you discover a genuinely necessary deviation, document it in NOTES.md with: (a) what the spec said, (b) what you did instead, (c) why. Do not silently invent. Examples of acceptable deferrals:
- The exact QR-encoder library (server-side qrcode lib vs client-side JS).
- Whether
live_histogramis pushed on every submit or throttled to once per 500ms (latter is fine, document it). - Choice of pinning vs latest for dependencies (prefer compatible-release pins).
18. Definition of done
The project is "done" when:
- All files in §12 exist (except observer.html, which is optional).
pytestpasses with all tests green.pytest --cov=app≥ 80% line coverage.uvicorn app.main:appstarts without errors with a valid.env.examples/week9_pool.jsonis a real, valid 10-question pool covering the W9 recap topics (CPU structure, datapath, control unit, FSM, hardwired vs microprogrammed). If you don't have domain knowledge to author content, generate plausible placeholder questions in the right format.README.mddocuments: install, env setup, dev run, test run, manual smoke-test steps.NOTES.mddocuments any deviations or non-obvious choices.- The load simulation test (
test_load_simulation.py) passes with 50 simulated students.