Host live quizzes in seconds. Create a quiz, share the code, play live together.
Kahoot-lite built end-to-end on the Cloudflare Workers suite: D1, KV, Durable Objects (with WebSocket hibernation), Cron Triggers, Turnstile. Edge-native, free-tier-safe, mobile-first for participants.
Demo: https://quizmo.giova.dev
Email-only auth (OTP, no passwords). Sessions persist to D1 so results survive closing tabs, Worker restarts, and DO eviction.
questionStartedAtMs)basePoints × max(0.5, timeLeft / total) + first-correct bonus/login + /join, per-email + per-IP rate limits on OTP send, single-use OTP codes| Layer | Choice | Why |
|---|---|---|
| Runtime | Cloudflare Workers (@sveltejs/adapter-cloudflare) — Workers, not Pages |
Edge-native, DO support, free tier covers the target scale. |
| Framework | SvelteKit 2 + Svelte 5 (runes only) | SSR + SPA hybrid, progressive enhancement, small runtime. |
| State | Durable Object QuizSession with SQLite storage, WebSocket hibernation, alarms |
Textbook DO fan-out (1 host → N participants). Hibernation keeps idle cost at zero. |
| Database | D1 via Drizzle ORM, drizzle-valibot for auto-derived schemas + hand-written refinements |
Single source of truth: schema.ts → TS types + runtime validators + migrations. |
| Cache | Two KV namespaces (auth + quiz) with mandatory key prefixes, 1-hour TTL on lookups |
Isolates blast radius of auth-side eviction pressure from quiz runtime. |
| Auth | better-auth email-OTP plugin + Resend for delivery |
No passwords, no OAuth dependencies, 6-digit code, single-use + invalidate-on-resend. |
| Validation | Valibot schemas shared server/client | Smaller bundle than Zod, same ergonomics. |
| UI | shadcn-svelte namespace primitives, Tailwind v4 via @tailwindcss/vite, @lucide/svelte icons |
Own the code, not the library. Semantic CSS-var tokens — zero hardcoded colors in the app. |
| Forms | SvelteKit form actions + use:enhance |
Progressive enhancement free, server is source of truth. |
| Errors | AppError + throwAppError on the server, neverthrow Result<T, AppError> on the client |
No try/catch for flow control. Errors carry typed codes end-to-end. |
| Anti-bot | Cloudflare Turnstile (Managed mode) | No CAPTCHA wall, invisible most of the time, escalates only when needed. |
| Testing | Vitest + @cloudflare/vitest-pool-workers for DO/D1/KV integration tests (58 tests) |
Real Worker runtime, not mocks. DO state machine + storage write-through covered. |
| Tooling | bun (runtime + package manager), Prettier + prettier-plugin-sort-imports + Tailwind plugin |
Fast installs, clean commits. |
A few details worth flagging for anyone reading the code:
Maps are cache-only. Every mutation writes through ctx.storage before broadcasting. Alarms that wake a cold instance rehydrate from storage — an alarm firing on an empty in-memory state is treated as normal, not a bug.quiz_sessions.code UNIQUE in D1. KV holds code → sessionId with 1-hour TTL; miss falls through to D1.INSERT ... WHERE (SELECT COUNT(*) ...) < N so two concurrent creates at the cap boundary can't both slip through./api/session/[sessionId]/ws Worker route takes the raw upgrade Request and returns stub.fetch(request). The DO itself calls ctx.acceptWebSocket(server) — accepting in the Worker would break hibernation and charge a Worker invocation per message.join, it signs participantId and returns the token; the client stores {participantId, participantToken} in sessionStorage. Reconnect verifies in constant time via crypto.subtle.verify.scheduled(), not HTTP: the nightly cleanup + session reconciliation is a real Worker scheduled export, not a publicly reachable /api/cron/* endpoint. No spoofable cf-worker header guard.scheduled into a single bundle: @sveltejs/adapter-cloudflare emits only export default { fetch } on the worker entry — there's no adapter hook to inject additional named exports, and Cloudflare Workers requires both QuizSession (the DO class) and scheduled (the cron handler) as named exports alongside the default. scripts/inject-do-exports.mjs bridges the gap: a post-build step that esbuild-compiles both modules through a single synthetic virtual entry (not two separate passes — that deduplicates shared Drizzle / Valibot symbols so wrangler doesn't reject the deploy with The symbol 'drizzle' has already been declared), renames the top-level identifiers to avoid colliding with the re-export names, and appends the bundle + a trailing export { QuizSession, scheduled } to .svelte-kit/cloudflare/_worker.js. See the header comment in scripts/inject-do-exports.mjs for the full rationale.More detail in the design + implementation rules under .claude/rules/.
src/
├── app.css # Tailwind + Quiz Soft tokens
├── app.d.ts
├── app.html
├── hooks.server.ts # auth init per request, handleError fallback
├── routes/
│ ├── +page.svelte # landing (product-first copy)
│ ├── +error.svelte
│ ├── +layout.{svelte,server.ts}
│ ├── login/ # email OTP
│ ├── join/ # participant entry (code + nickname + Turnstile)
│ ├── play/[sessionId]/ # participant live view
│ ├── host/[sessionId]/ # host live console
│ ├── dashboard/ # quiz CRUD + session history
│ │ ├── new/
│ │ ├── [id]/
│ │ │ └── sessions/
│ │ │ └── [sessionId]/
│ │ └── _components/quiz-form.svelte
│ └── api/
│ ├── auth/[...all]/ # better-auth
│ └── session/[sessionId]/ws/ # WS → DO forwarder
└── lib/
├── components/
│ ├── ui/ # shadcn-svelte primitives
│ ├── leaderboard.svelte
│ └── mode-toggle.svelte
├── server/
│ ├── auth/ # better-auth config, Resend, rate limits, Turnstile
│ ├── cron/ # scheduled() handler + cleanup/reconcile
│ ├── db/ # Drizzle schema, client, validators
│ ├── do/ # QuizSession DO + WS protocol + scoring
│ └── errors.ts # AppError + httpStatusFor + guardAppError
└── utils/
└── result.ts # neverthrow wrappers
Requires Bun ≥ 1.0, Node ≥ 20, and a Cloudflare account (for remote D1/KV/DO — Quizmo uses wrangler dev --remote against production bindings because D1 local dev has limitations).
bun install
bun run dev # Vite dev on http://localhost:5173
bun run check # wrangler types + svelte-check (zero errors gate)
bun run test # vitest unit + @cloudflare/vitest-pool-workers integration
bun run build # SvelteKit + adapter-cloudflare + DO/cron bundle injection
bun run lint # prettier --check + eslint
wrangler deploy --dry-run
wrangler deploy
Secrets needed for a full end-to-end run:
RESEND_API_KEY — for OTP email deliveryBETTER_AUTH_SECRET — session signing (generate once via openssl rand -base64 32)TURNSTILE_SECRET_KEY — server-side Turnstile verificationPlus the public vars in wrangler.jsonc: TURNSTILE_SITE_KEY, APP_URL, RESEND_FROM_EMAIL.
Detailed rules auto-load from .claude/rules/ for any AI coding agent:
error-handling.md — server throws AppError, client uses neverthrow, no try/catch for flow controlsveltekit-frontend.md — Svelte 5 runes, CF compat, Drizzle inferred typesui-components.md + ui-checklist.md — shadcn-svelte primitives only, semantic tokens, namespace importsforms.md — SvelteKit actions + use:enhance + Valibotdates.md — @internationalized/date is the only date librarycf-workers-backend.md — bindings map, D1/KV/DO patterns, storage write-through, WS protocol