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>
13 KiB
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 devon :5173 with proxy → Node on :3001 - Production:
npm run buildcompiles React to/client/dist; Node serves/client/distas static + all/apiroutes on port 8080
3. Database Schema
better-sqlite3, schema created on startup in db.js.
-- 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
- Landing page — enter email → POST /api/auth/request
- "Check your email" screen
- Click magic link → GET /api/auth/verify → session token in URL → stored in localStorage
- Redirect to
/onboarding(mnemonic builder) - 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
- Onboarding complete →
/game(Novice)
Practice loop
- Load challenge: random letter/word from current level, weighted by accuracy (lower accuracy = more frequent)
- Display signal: visual flash (FlashArea) + audio (useMorseAudio), with Play button
- Player types answer, submits
- Correct: show their mnemonic phrase as reinforcement, +points, streak++
- Incorrect: "Try again, adventurer!" — replay, no penalty
- 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
- Landing page — if
localStoragehas a session token,App.tsximmediately fetches/api/auth/me - Valid session → redirect to
/gameat current level (no email entry needed) - Expired/missing → show login page
Admin
/adminpath, 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.
// 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 }wherecurrentSignal: 'dot'|'dash'|null currentSignaldrivesFlashAreaCSS 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:
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
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:
// 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.
# --- 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
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