# MorseQuest MVP Design Spec **Date:** 2026-04-29 **Deployment:** morsequest.keylinkit.net (Docker on VPS) **Purpose:** Pack 404 pilot — feedback-first, rough edges acceptable --- ## 1. Scope ### In scope (MVP) - Magic link email authentication (allegiance pattern) - Mnemonic creation system — the core differentiator - Novice level: 12 letters (A E T I N S H R D L U O) - Operator level: simple 3-letter words from Novice letters - Full Web Audio API + synchronized visual flash playback - Points, streaks, daily bonus scoring - Level-up flow (Novice → Operator) - Basic admin panel (user list, stats, per-user progress) - Docker deployment matching allegiance pattern ### Out of scope (Phase 2) - Levels 3–6 (General, Extra, Expert, Legend) - Ham radio license modules - Payment / Authorize.net integration - Leaderboards / social features - PWA offline mode - White-label / multi-tenant --- ## 2. Architecture **Frontend:** React 18 + TypeScript, Vite bundler **Backend:** Node.js HTTP server (no framework), better-sqlite3 **Auth:** Magic link email via nodemailer (mail.keylinkit.net:587) **Deployment:** Single Docker container, node:20-alpine, named volume ### Project structure ``` morsequest/ ├── client/ │ ├── index.html │ └── src/ │ ├── main.tsx │ ├── App.tsx # routing: login / onboarding / game / admin │ ├── lib/ │ │ ├── morse.ts # MORSE map, timing constants, word pools │ │ └── audio.ts # MorseAudioEngine class │ ├── hooks/ │ │ ├── useAuth.ts # session token, /api/auth/me │ │ ├── useMorseAudio.ts # wraps MorseAudioEngine, exposes currentSignal │ │ └── useProgress.ts # score, streak, level from /api/progress │ ├── pages/ │ │ ├── LoginPage.tsx │ │ ├── OnboardingPage.tsx # mnemonic builder │ │ ├── GamePage.tsx # practice loop │ │ └── AdminPage.tsx │ └── components/ │ ├── FlashArea.tsx # visual signal display │ ├── MnemonicBuilder.tsx # step-through letter mnemonic entry │ ├── PracticeCard.tsx # challenge + answer input │ └── ScoreBar.tsx ├── server/ │ ├── server.js # Node HTTP, serves /client/dist, handles /api routes │ ├── db.js # better-sqlite3, schema init, all query functions │ └── mailer.js # nodemailer magic link (allegiance pattern) ├── data/ # SQLite db lives here (Docker volume) ├── Dockerfile ├── docker-compose.yml └── package.json # root scripts: dev, build, start ``` ### Dev vs production - **Dev:** `vite dev` on :5173 with proxy → Node on :3001 - **Production:** `npm run build` compiles React to `/client/dist`; Node serves `/client/dist` as static + all `/api` routes on port 8080 --- ## 3. Database Schema `better-sqlite3`, schema created on startup in `db.js`. ```sql -- Auth (allegiance pattern) CREATE TABLE profiles ( id INTEGER PRIMARY KEY AUTOINCREMENT, email TEXT UNIQUE NOT NULL, display_name TEXT, created_at INTEGER NOT NULL, last_seen INTEGER NOT NULL ); CREATE TABLE magic_tokens ( token TEXT PRIMARY KEY, profile_id INTEGER NOT NULL, expires_at INTEGER NOT NULL, used INTEGER NOT NULL DEFAULT 0 ); CREATE TABLE sessions ( token TEXT PRIMARY KEY, profile_id INTEGER NOT NULL, created_at INTEGER NOT NULL, last_seen INTEGER NOT NULL ); -- Progress CREATE TABLE user_progress ( profile_id INTEGER PRIMARY KEY, level INTEGER NOT NULL DEFAULT 1, -- 1=Novice, 2=Operator score INTEGER NOT NULL DEFAULT 0, streak INTEGER NOT NULL DEFAULT 0, best_streak INTEGER NOT NULL DEFAULT 0, total_correct INTEGER NOT NULL DEFAULT 0, total_attempts INTEGER NOT NULL DEFAULT 0, last_session_at INTEGER, updated_at INTEGER NOT NULL ); -- Mnemonics — one row per letter per user CREATE TABLE user_mnemonics ( profile_id INTEGER NOT NULL, letter TEXT NOT NULL, phrase TEXT NOT NULL, created_at INTEGER NOT NULL, PRIMARY KEY (profile_id, letter) ); -- Per-letter accuracy CREATE TABLE letter_stats ( profile_id INTEGER NOT NULL, letter TEXT NOT NULL, correct INTEGER NOT NULL DEFAULT 0, attempts INTEGER NOT NULL DEFAULT 0, updated_at INTEGER NOT NULL, PRIMARY KEY (profile_id, letter) ); ``` --- ## 4. API Routes All routes return JSON. Auth routes match allegiance pattern. Protected routes require `Authorization: Bearer ` header. ``` POST /api/auth/request { email } → sends magic link GET /api/auth/verify?token= → 302 to /?session= GET /api/auth/me → { id, email, display_name } [auth] DELETE /api/auth/session → log out [auth] GET /api/progress → { level, score, streak, ... } [auth] POST /api/progress/answer { letter, correct } [auth] GET /api/mnemonics → { A: "phrase", ... } [auth] POST /api/mnemonics { letter, phrase } [auth] GET /admin → admin HTML (ADMIN_PASSWORD gate) GET /api/admin/users → user list with stats [admin-token] GET /api/admin/stats → global aggregate stats [admin-token] # [admin-token]: request must include Authorization: Bearer # AdminPage.tsx stores the password in sessionStorage after the user enters it, # and attaches it as a bearer token to all /api/admin/* requests. # Server checks: token === process.env.ADMIN_PASSWORD ``` --- ## 5. Core User Flows ### First visit 1. Landing page — enter email → POST /api/auth/request 2. "Check your email" screen 3. Click magic link → GET /api/auth/verify → session token in URL → stored in localStorage 4. Redirect to `/onboarding` (mnemonic builder) 5. Step through all 12 Novice letters: - Show letter + Morse pattern (dot/dash display) - Play audio + flash on request - Player types phrase — real-time validation: - Word count must equal signal element count - Words ≤2 chars = dot, words ≥3 chars = dash - Save → 50 pts, next letter - Skip available — skipped letters marked, caps rank at Operator 6. Onboarding complete → `/game` (Novice) ### Practice loop 1. Load challenge: random letter/word from current level, weighted by accuracy (lower accuracy = more frequent) 2. Display signal: visual flash (FlashArea) + audio (useMorseAudio), with Play button 3. Player types answer, submits 4. **Correct:** show their mnemonic phrase as reinforcement, +points, streak++ 5. **Incorrect:** "Try again, adventurer!" — replay, no penalty 6. 10 consecutive correct → level-up modal ### Session token extraction After the magic link redirect, `App.tsx` reads `?session=` from the URL on mount, writes it to `localStorage`, strips it from the URL with `history.replaceState`, then fetches `/api/auth/me`. All subsequent API calls attach `Authorization: Bearer `. ### Returning user 1. Landing page — if `localStorage` has a session token, `App.tsx` immediately fetches `/api/auth/me` 2. Valid session → redirect to `/game` at current level (no email entry needed) 3. Expired/missing → show login page ### Admin - `/admin` path, guarded by ADMIN_PASSWORD (same env var pattern as allegiance) - Table: all users, email, display name, level, score, joined, last seen - Click user → per-letter accuracy breakdown, mnemonic completion count --- ## 6. Audio Engine `client/src/lib/audio.ts` — pure TypeScript, no dependencies. ```typescript // Timing: ITU standard, pilot starts at 10 WPM // dot=120ms, dash=360ms, element gap=120ms, char gap=360ms, word gap=840ms const MORSE: Record = { A:'.-', B:'-...', /* ... */ } class MorseAudioEngine { private ctx: AudioContext private stopFlag = false // Emits events: 'signal-start' | 'signal-end' with payload {type:'dot'|'dash'} on(event: string, cb: Function): void async play(text: string, wpm?: number): Promise stop(): void } ``` `useMorseAudio()` hook: - Wraps `MorseAudioEngine` - Exposes `{ play, stop, isPlaying, currentSignal }` where `currentSignal: 'dot'|'dash'|null` - `currentSignal` drives `FlashArea` CSS class toggle `FlashArea` component: - Large colored div — off-state: dark parchment, active-state: quest gold (#D4AF37) - Dot flash: 120ms, Dash flash: 360ms - Synchronized to audio engine events (not setTimeout-based) --- ## 7. Mnemonic Validation Logic Real-time validation as player types: ```typescript function validatePhrase(phrase: string, morsePattern: string): ValidationResult { const words = phrase.trim().split(/\s+/) const elements = morsePattern.split('') // '.', '-' if (words.length !== elements.length) { return { valid: false, message: `Need ${elements.length} words, got ${words.length}` } } for (let i = 0; i < words.length; i++) { const isDot = elements[i] === '.' const isShort = words[i].length <= 2 // 1-2 chars = dot if (isDot && !isShort) return { valid: false, message: `Word ${i+1} should be short (dot)` } if (!isDot && isShort) return { valid: false, message: `Word ${i+1} should be long (dash)` } } return { valid: true } } ``` Letters with Morse patterns for Novice set: ``` A .- E . T - I .. N -. S ... H .... R .-. D -.. L .-.. U ..- O --- ``` --- ## 8. Scoring ```typescript function calculatePoints(level: number, streak: number, isFirstSessionToday: boolean): number { const base = level * 10 // Novice=10, Operator=20 const streakMult = streak >= 10 ? 2 : 1 const dailyMult = isFirstSessionToday ? 1.25 : 1 return Math.floor(base * streakMult * dailyMult) } // Mnemonic creation: flat 50 pts per letter // Level-up trigger: 10 consecutive correct ``` --- ## 9. Content Data Static in `client/src/lib/morse.ts`: ```typescript // Novice word pool (Operator level) — 3-letter words from A,E,T,I,N,S,H,R,D,L,U,O export const OPERATOR_WORDS = [ 'SEA', 'SUN', 'TAN', 'RAN', 'HIT', 'RUN', 'ANT', 'DEN', 'HEN', 'INN', 'OAR', 'OUR', 'USE', 'TIN', 'SIN', 'HIS', 'AIR', 'EAR', 'OIL', 'AND', 'THE', 'HOT', 'NET', 'SET' ] ``` Challenge selection weights: letters/words with lower `correct/attempts` ratio selected more frequently. Avoids repeating the same letter 3× in a row. --- ## 10. Docker & Deployment ### Dockerfile Two-stage build: build stage installs all deps (including Vite) and compiles the React app; runtime stage copies only the built output and production deps. ```dockerfile # --- Build stage --- FROM node:20-alpine AS builder WORKDIR /app RUN apk add --no-cache python3 make g++ COPY package.json package-lock.json* ./ RUN npm install --no-audit --no-fund # all deps, including vite COPY . . RUN npm run build # vite build → client/dist # --- Runtime stage --- FROM node:20-alpine ENV NODE_ENV=production PORT=8080 WORKDIR /app RUN apk add --no-cache python3 make g++ # for better-sqlite3 native rebuild COPY package.json package-lock.json* ./ RUN npm install --omit=dev --no-audit --no-fund COPY --from=builder /app/client/dist ./client/dist COPY server/ ./server/ RUN mkdir -p /app/data && chown -R node:node /app USER node EXPOSE 8080 HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ CMD wget -q --spider http://localhost:8080/ || exit 1 CMD ["node", "server/server.js"] ``` ### docker-compose.yml ```yaml services: morsequest: image: morsequest:latest container_name: morsequest restart: unless-stopped ports: - "127.0.0.1:3004:8080" volumes: - morsequest-data:/app/data environment: PORT: "8080" NODE_ENV: production SMTP_HOST: mail.keylinkit.net SMTP_PORT: "587" SMTP_USER: ${SMTP_USER} SMTP_PASS: ${SMTP_PASS} ADMIN_PASSWORD: ${ADMIN_PASSWORD} APP_URL: https://morsequest.keylinkit.net volumes: morsequest-data: ``` Host nginx proxies `morsequest.keylinkit.net` → `127.0.0.1:3004` (same pattern as allegiance). --- ## 11. Brand Implementation From brand guidelines: - **Colors as CSS custom properties:** `--color-gold: #D4AF37`, `--color-blue: #4169E1`, `--color-parchment: #F5E6D3`, `--color-forest: #3C2415` - **Fonts:** Cinzel (headers, Google Fonts), Open Sans (body), Source Code Pro (Morse display) - **Voice:** "Try again, adventurer!" not "Incorrect". "Quest complete!" not "Level up". - **Existing SVG assets** from `assets/` used directly (logos, icons, badges) --- ## 12. Environment Variables ``` SMTP_HOST mail.keylinkit.net SMTP_PORT 587 SMTP_USER noreply@morsequest.keylinkit.net (or similar) SMTP_PASS ADMIN_PASSWORD APP_URL https://morsequest.keylinkit.net PORT 8080 NODE_ENV production ```