Files
kitadmin cb0ffb7e6b Add MVP design spec and update .gitignore
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>
2026-04-29 22:41:15 +00:00

13 KiB
Raw Permalink Blame History

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 36 (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.

-- 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.

// 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:

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.net127.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