cb0ffb7e6b
Covers architecture, schema, user flows, audio engine, mnemonic validation, scoring, content data, Docker deployment, and brand implementation for the Pack 404 pilot. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
391 lines
13 KiB
Markdown
391 lines
13 KiB
Markdown
# 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 <session_token>` header.
|
||
|
||
```
|
||
POST /api/auth/request { email } → sends magic link
|
||
GET /api/auth/verify?token= → 302 to /?session=<token>
|
||
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 <ADMIN_PASSWORD>
|
||
# 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=<token>` 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 <token>`.
|
||
|
||
### 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<string, string> = { 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<void>
|
||
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 <password>
|
||
ADMIN_PASSWORD <admin panel password>
|
||
APP_URL https://morsequest.keylinkit.net
|
||
PORT 8080
|
||
NODE_ENV production
|
||
```
|