Quizzly is a standalone product: Kahoot-style live multiplayer quiz on Frappe. Guests join with PIN or QR, no login. Server-authoritative anti-cheat.
Naming: product/brand = Quizzly everywhere. App quizzly, module Quizzly, DocType prefix QZ, event prefix qz_, Redis prefix qz:.
- Host starts a game: big screen shows game PIN + QR code + join URL.
- Players go to join page, enter PIN (or scan QR which prefills it), pick a nickname, land in lobby. No account needed.
- Lobby: names pop onto host screen as they join. Host can kick players and lock the lobby. Nickname generator option avoids inappropriate names.
- Game: all players get each question simultaneously with a countdown (default 20s, per-question override). Up to 1000 points per question, scaled by answer speed. Some question types carry no points.
- Between questions: correct answer reveal, answer distribution bar chart, top-5 leaderboard, streak callouts ("X is on a 3 answer streak"), position-change messages.
- End: top-3 podium with animation.
- Classic mode: question + options on the shared host screen, players see only 4 colored shape buttons. Remote-friendly variant shows full question on player device too.
- Every player sees the question on own device, works without shared screen.
- Power-ups: x2 points, immunity (second chance), power play. Earned by streaks/correct answers.
- Memes between questions, customizable avatars, music.
- Nickname generator gives 3 safe options to pick from.
| v1 (core game) | v2 (juice) |
|---|---|
| PIN + QR join, nickname, lobby | Avatars |
| Kick + lobby lock + name filter | Nickname generator |
| Synced questions, per-question timer | Memes between questions |
| Speed-scaled points (Kahoot formula) | Power-ups |
| Streak bonus | Music/sfx |
| Answer distribution + top-5 between questions | Team mode |
| Top-3 podium | Shared-screen mode (question only on host screen) |
| Host controls: skip, end early | Question images |
v1 player device shows the full question (remote-friendly). Colored shape buttons for options keep the Kahoot feel.
- App
quizzly, moduleQuizzly. Stable Frappe v16, no nightly/experimental features. - Push:
frappe.publish_realtime(event, data, room=...)for everything server to client. One event name per session:qz_session_{pin}, payload carriestypefield (lobby_update, countdown, question, question_closed, leaderboard, podium, kicked, session_ended). Client subscribes once, switches on type. - Pull: every client to server action is a whitelisted HTTP API with
allow_guest=Truewhere needed. Never socket emit for actions (custom socket handlers are experimental/nightly in Frappe). - Game loop: one RQ background job per active session (
queue="long",job_id=f"qz_session_{name}",deduplicate=True). Loop: publish question, sleep window, close, score, publish stats, next. Timeout sized to quiz length (questions x seconds + margin). - Timing: no per-second server ticks. Question payload carries server-set
deadline_ts(epoch float). Client renders its own countdown. Server validates every submit against Redis state. - Hot state in Redis (
frappe.cache), keyed per session: active question index, question opened_at, deadline_ts, session status. DB is the durable record; Redis is the fast gate for submit validation. - Guest sockets, Phase 0 spike: verify a guest (no login) socket.io connection receives events published to our room on v16. Expected path: website room / explicit room param. If guests cannot receive room events on stable v16, fallback is a 1s short-poll state API for players (host stays on socket). Decide in the spike, do not build both.
- Host (logged-in Desk user with Quiz Host role) creates QZ Session from a quiz. PIN generated, status Lobby.
- Player: GET join page, enters PIN + nickname (or QR link prefills PIN).
join_sessionAPI validates PIN, lobby open, nickname unique + clean, creates QZ Participant, returnsparticipant_token. Token stored in localStorage. Lobby update published. - Host locks lobby (optional) and starts. Status Active, game loop enqueued.
- Per question: loop writes Redis state, publishes
question(text + options, NO correct answer, deadline_ts, index, total), sleeps until deadline + grace, closes question in Redis, scores answers, publishesquestion_closed(correct option, distribution, top-5, streaks). - Host advances (or auto-advance after N seconds, host setting). Loop continues.
- After last question: final ranks computed,
podiumpublished (top 3 + full leaderboard), status Ended, results persisted.
Host drop does not kill the game: the loop is server-driven, host can reload and resume control (host screen state API). Manual "end session" API for the host.
Authored in Desk by hosts. autoname: format:QZ-{####}.
| field | type | notes |
|---|---|---|
| title | Data, reqd | |
| description | Small Text | |
| default_time_limit | Int, default 20 | seconds per question |
| questions | Table -> QZ Question |
| field | type | notes |
|---|---|---|
| question_text | Small Text, reqd | |
| option_1..option_4 | Data, reqd | canonical order, server-side only |
| correct_option | Select 1\n2\n3\n4, reqd | NEVER serialized to players until close |
| time_limit | Int | blank = quiz default |
| points_multiplier | Select 0\n1\n2, default 1 | 0 = fun question, 2 = double |
Child rows have stable name (row id); answers reference it.
autoname: hash. Fields:
| field | type | notes |
|---|---|---|
| quiz | Link QZ Quiz, reqd | |
| host | Link User, reqd | set server-side to session creator |
| game_pin | Data, unique | 6 digits, generated, reused pins avoided while active |
| status | Select Lobby\nActive\nEnded\nCancelled | |
| lobby_locked | Check | |
| auto_advance | Check, default 1 | else host advances |
| randomize_answer_order | Check, default 1 | per-participant display shuffle |
| current_question | Int, default -1 | index into quiz questions |
| started_at / ended_at | Datetime |
autoname: hash.
| field | type | notes |
|---|---|---|
| session | Link QZ Session, reqd | |
| nickname | Data, reqd | unique per session (validated in controller) |
| token_hash | Data | sha256 of participant_token; raw token never stored |
| score | Int, default 0 | |
| streak | Int, default 0 | current correct streak |
| rank | Int | final rank, set at end |
| kicked | Check | kicked tokens rejected on all APIs |
| joined_at | Datetime |
autoname: hash.
| field | type | notes |
|---|---|---|
| session | Link QZ Session, reqd | |
| participant | Link QZ Participant, reqd | |
| question_row | Data, reqd | child row name of QZ Question |
| selected_option | Select 1\n2\n3\n4 | |
| is_correct | Check | computed server-side |
| response_ms | Int | server receive time minus question opened_at |
| points | Int | computed server-side |
Composite unique constraint (participant, question_row) via on_doctype_update():
# qz_answer.py
def on_doctype_update():
frappe.db.add_unique("QZ Answer", ["participant", "question_row"])Duplicate submits die at the DB level regardless of race conditions.
qz:{session}:state -> {status, q_index, question_row, opened_at, deadline_ts} TTL: window + 30s
qz:{session}:answered:{q} -> SET of participant names (fast duplicate pre-check) TTL: window + 30s
- QZ Quiz, QZ Session: role Quiz Host (create/write own), System Manager all.
if_ownerfor hosts. - QZ Participant, QZ Answer: no direct role access for anyone but System Manager. All reads/writes go through whitelisted APIs. Guests never touch the REST resource API.
- correct_option: exists only in QZ Question, which guests can never read. Question delivery API builds the payload explicitly, field never included.
Guest APIs (allow_guest=True, rate-limited via frappe.rate_limiter):
| endpoint | args | does |
|---|---|---|
join_session |
pin, nickname | validate lobby open + not locked, nickname clean/unique, create participant, return token + session snapshot |
get_state |
pin, token | current state for reconnect: status, current question (no answer), deadline_ts, own score. Also the polling fallback |
submit_answer |
pin, token, question_row, selected_option | full validation gauntlet (below), store answer, return ack only (no correctness leak) |
leave_session |
pin, token | mark left, lobby update |
Host APIs (login + Quiz Host role, host must own session):
| endpoint | does |
|---|---|
create_session |
from quiz, generate PIN |
lock_lobby / unlock_lobby |
|
kick_participant |
sets kicked, publishes kicked targeted payload |
start_session |
enqueue game loop |
next_question |
when auto_advance off |
skip_question |
close current early |
end_session |
abort loop, straight to podium |
- Rate limit per token.
- Session exists, status Active.
- Token hash matches a participant of this session, not kicked.
- Redis state: question_row equals the currently active question.
server_now <= deadline_ts + 1.0(grace for network only).- Redis answered-set pre-check, then DB insert; unique constraint is the final word.
- Score computed server-side, from server timestamps.
Return only {"ok": true}. Correctness revealed to everyone at question close, never per-submit.
Kahoot formula, computed at question close time or on submit:
if incorrect: 0, streak = 0
if correct:
base = round((1 - (response_ms / window_ms) / 2) * 1000) # 500..1000
streak += 1
bonus = min(streak - 1, 5) * 50 # 0..250
points = (base + bonus) * points_multiplier
response_ms is server receive time minus server opened_at. Client timing never used.
- Correct answer never in any payload until question closed.
- Server-set deadline, 1s network grace, all timestamps server-side.
- DB composite unique (participant, question_row): duplicates impossible.
- Active-question gate in Redis: cannot answer past/future questions.
- Tokens: 32-byte random, only sha256 stored, kicked flag kills token.
- Per-participant answer-order shuffle on display (seeded by token client-side); canonical option ids on the wire, so shuffle defeats neighbor-copying, not scoring.
- Question bank unreadable by guests; payloads hand-built in API code.
- Rate limiting on all guest APIs; nickname profanity filter.
- Scores computed and persisted server-side only.
Residual: one human, several devices/tabs, several nicknames. Host kick + lobby lock is the mitigation; only proctoring would close it fully. Accepted.
Vue 3 + frappe-ui + Vite SPA in frontend/. frappe-ui gives Frappe-aware composables (useCall, useList, useDoc) and a Vite plugin with dev proxy + DocType type generation. socket.io-client for realtime.
Wiring (standard Frappe SPA pattern):
- SPA lives in
apps/quizzly/frontend/; production build outputs toapps/quizzly/quizzly/public/frontend(bench build --app quizzly). - Served via
website_route_rulesin hooks.py:{"from_route": "/quizzly/<path:app_path>", "to_route": "quizzly"}. - Vite dev server proxies
/apito the runningbench startbackend.
Two route groups:
Player (guest, mobile-first):
/joinPIN entry (QR link/join?pin=XXXXXXprefills), nickname screen./playone screen, state machine: lobby -> get-ready countdown -> question (4 colored shape buttons, local countdown bar) -> locked-in wait -> result interstitial (correct/wrong, +points, rank, top-5) -> podium.- Reconnect: token in localStorage,
get_stateon load rejoins mid-game.
Host (logged in, big-screen-first):
/hostquiz picker -> session created: lobby screen with giant PIN + QR (client-side QR lib) + join URL + player names grid + lock/kick/start.- Game view: current question, live answer count, distribution reveal, top-5, next/skip/end controls.
- Podium screen: top 3 animation, full leaderboard, export results.
Quiz authoring: Frappe Desk in v1 (free CRUD UI on QZ Quiz). Custom authoring UI in SPA later.
- bench app
quizzly, site, module Quizzly, roles. - SPA scaffold.
- SPIKE: guest socket.io receives room-published events on v15? Yes: socket push for players. No: 1s short-poll
get_statefor players, socket for host. Decide, delete the losing path.
- DocTypes: QZ Quiz, QZ Question, QZ Session, QZ Participant, QZ Answer (+ unique index).
- Host APIs: create_session, PIN generation. Join API + token issue.
- Lobby end-to-end: join via PIN/QR, names appear live on host screen, kick, lock.
- RQ game loop, Redis state, question publish with deadline.
- submit_answer with full gauntlet. Scoring + streaks.
- question_closed payload: correct answer, distribution, top-5, streak callouts.
- Podium + persisted results.
- Player screens polished: countdown bar, shape buttons, interstitials, podium.
- Host screens: distribution charts, leaderboard, controls (skip, end, auto-advance toggle).
- Reconnect flows both sides. Host-drop resilience.
- Nickname profanity filter.
- Avatars, nickname generator, memes between questions, sounds.
- Power-ups, team mode, shared-screen mode, question images.
- Results export (CSV) for host, session history.
- Submit after deadline+grace rejected.
- Duplicate submit rejected (API race: fire two in parallel, DB constraint holds).
- Submit for non-active question rejected.
- Kicked token rejected everywhere.
- Wrong PIN / locked lobby / duplicate nickname rejected.
- Scoring: boundary response times (0ms, window, window+grace), streak reset, multiplier 0 and 2.
- Correct answer absent from every payload before close (assert on serialized events).
- Reconnect mid-question gets correct remaining time.
- Kahoot host flow: https://support.kahoot.com/hc/en-us/articles/360039422694-How-to-host-a-live-kahoot
- Kahoot join: https://support.kahoot.com/hc/en-us/articles/360039890713-Kahoot-join-How-to-join-a-Kahoot-game
- Kahoot live settings: https://support.kahoot.com/hc/en-us/articles/115016055107-Live-game-settings
- Kahoot vs Quizizz: https://triviamaker.com/kahoot-vs-quizziz/ , https://quizizz.com/home/quizizz-vs-kahoot
- Frappe realtime: https://docs.frappe.io/framework/user/en/api/realtime
- Frappe background jobs: https://docs.frappe.io/framework/user/en/api/background_jobs