Project wiki Β· handoff document

josh3.drfulmer.com

Everything you need to understand, operate, debug, and extend this app. Written for a new engineer or AI agent picking up the project cold.

1. Project at a Glance

josh3.drfulmer.com is a personalized adaptive-learning web app for Joshua, a 10-year-old autistic student. It is a single-tenant app: one student, one parent, one device set. It runs at https://josh3.drfulmer.com on a single VPS (colo), with a private GitHub repo (bachmanfulmer/josh3-learning) as the source of truth and a one-command deploy from local to colo.

Domain
josh3.drfulmer.com
Stack
Next.js 16 + React 19 + FastAPI + SQLite
Student
Joshua, age 10
Theme
Sonic / Mario
Repo
github.com/bachmanfulmer/josh3-learning (private)
Server
bfulmer@colo (107.175.150.86)
Last checkpoint
C20 β€” Git version control
Started
2026

The app is not a product. It is a bespoke learning environment for one specific kid, optimized for the way HE learns. Every design choice in this codebase is downstream of that constraint. If a future contributor wants to make it multi-tenant or generalize the pedagogy, they should expect to revisit almost every section below.

2. The Student: Joshua

Joshua is 10 years old, autistic, and highly motivated by video games. He is the only student this app is designed for. Every pedagogical and aesthetic decision in the project is calibrated to what works for him specifically.

What works

  • Video-game theming as primary motivator. Sonic the Hedgehog and Super Mario Bros characters, gold rings as the reward currency, Chaos Emeralds as long-term collectibles, Mario bricks and pipes as visual accents. Without the theming, Joshua does not engage with the content.
  • Short, frequent sessions. 5–15 minutes is the sweet spot. The fatigue detection (see Β§10) is designed to surface a "Time for a break?" banner when sustained accuracy drops, not to block him.
  • Concrete, literal language. No idioms, no metaphors in question prompts, no abstract framing. "What is 12 + 18?" not "If you have 12 and add 18, what do you get?" The persona feedback ("Way past cool, Joshua!") is allowed to be enthusiastic but the questions themselves stay grounded.
  • Variable-ratio reward. Some quests pay a 5Γ— ring bonus at random, like a slot machine. This is the most reliable engagement lever in the system.
  • Personalized personas. The default mascot shifts with his engagement state: Sonic when he's high, Mario at baseline, Tails when he's low. Each character has a stable voice in the feedback.

What doesn't work

  • Long explanations. If a hint is more than two sentences, he tunes out. Hints are short and use the same vocabulary as the question.
  • Auto-advance. (Removed in C19.) When a celebration screen jumped ahead on its own, he didn't have time to feel the win. Now the verdict screen waits for him to click Next Quest.
  • Failure without a path forward. A wrong answer is never the end of the line β€” the cascade (see Β§11) gives him a hint, then an easier question, then a partial reward, with no rings lost.
  • Idioms and abstraction. "Reading between the lines" type questions are off the table for now. He reads literally; we work with that.

Engagement states

The system tracks a daily engagement state for Joshua: high, baseline, orlow. This is set by his morning check-in (he picks a Sonic/Mario/Luigi/Tails/Luigi-again button) or inferred from recent accuracy. The state drives which persona appears as the mascot, which difficulty of quest he gets, and whether the lore system unlocks.

3. The Pedagogy

The curriculum is deliberately narrow and concrete. We are not building a complete K-12 system; we are building the right practice for one kid right now.

Math (4th grade)

  • Arithmetic: addition, subtraction, multiplication, division
  • Number sense: largest, smallest, ordering, place value
  • Comparisons: more than, less than, equal to
  • Simple word problems with concrete scenarios (no abstractions)
  • Mental math friendly: small enough to do in his head

Reading (3rd grade)

  • Short narrative passages (4–8 sentences)
  • Literal comprehension: "Who did X?" "What happened at Y?"
  • Sequence questions: "What came first?"
  • No inference, no "why do you think", no subtext
  • Two formats (see Β§10 for adaptive selection):MC (multiple choice) and DnD (drag-and-drop sentence ordering)

Logic

  • Pattern recognition
  • Simple sequences
  • Counting and grouping
  • If/then with concrete scenarios

Lore (multi-step story)

A separate quest type that tells a multi-step story (3–5 steps) where each step is a math/reading/logic problem. Correct answers unlock a paragraph of narrative that builds a Sonic/Mario-flavored story. The story resets when complete; the rings do not. Lore unlocks only in high engagement and is the primary long-term reward beyond the daily ring balance.

4. The Aesthetic

Sonic the Hedgehog and Super Mario Bros are not just decoration; they are the engagement engine. Without them, Joshua doesn't open the app. The aesthetic is consistent across every screen.

Character roster

Four mascots, each with a 1024Γ—1024 transparent PNG (see Β§18 for the cache-bust history):

  • sonic β€” high engagement, the "way past cool" voice. Blue plate, gold ring accent at 1 o'clock.
  • mario β€” baseline, "here we go" voice. Red plate.
  • tails β€” low engagement, "let me show you" voice. Orange plate.
  • luigi β€” parent dashboard, "we can do it" voice. Green plate.

Color palette

  • sonic-blue: #0F4DA8
  • mario-red: #E52521
  • Accent: gold rings (#FACC15), Chaos Emeralds (greens)
  • Background: cream/white with subtle polka-dot pattern (bg-rings)

Decorative elements

components/Decor.tsx exports a small SVG library: Brick, Pipe,SpinningRing, StarBurst,HpBar, BrickDivider,SpeechBubble, Emerald. Used sparingly as section dividers and stat indicators.

Animation set

Defined in globals.css: bounce,float, wiggle, pop,sparkle, coinShine,levelUp, shake. Buttons have pixel-style 3D shadows for a retro game feel.

5. Tech Stack

Frontend

  • Next.js 16.3.1 (App Router, Turbopack default)
  • React 19.2.8
  • Tailwind CSS for styling
  • React Compiler enabled (reactCompiler: true, see Β§18)
  • babel-plugin-react-compiler@1.0.0 indevDependencies
  • No state library β€” React Context (UserProvider) for user, useState for everything else

Backend

  • FastAPI 0.141.1
  • SQLAlchemy 2.0.52 (Core, not ORM β€” we use raw SQL for clarity)
  • Pydantic for schemas
  • slowapi 0.1.10 for rate limiting
  • httpx for AI API calls
  • Python 3.12

Database

  • SQLite at /home/bfulmer/josh3-learning/data/josh3_learning.db
  • Schema migrations are hand-rolled inbackend/app/database.py with idempotentCREATE TABLE IF NOT EXISTS. No Alembic.
  • Backups: cron-driven backup-db.sh at 03:00 UTC, 7-day rotation, kept in data/backups/

AI

  • Provider: MiniMax athttps://api.minimax.io/v1, model MiniMax-M3
  • API key in backend/.env (chmod 600, gitignored)
  • Default in production builds: stubbed (AI_LIVE=0). The system runs without external calls; feedback text comes from a deterministic template. Real model is enabled per request via the AI_LIVE env var.
  • AI is used for: reading passages, persona feedback messages, hint text, lore unlocks. NOT for math/logic generation (those are deterministic and offline).

Infrastructure

  • Single VPS: bfulmer@colo (107.175.150.86, Xeon E3-1240, 31GB RAM, 458GB disk, Ubuntu 24.04)
  • nginx 1.24 as reverse proxy + TLS terminator
  • systemd for backend (josh3-backend.service) and frontend (josh3-frontend.service)
  • Let's Encrypt for TLS (auto-renew via certbot.timer)
  • Git for version control (private GitHub repo)

6. Architecture

High-level flow

Browser (Next.js client)
   β”‚
   β”‚  https://josh3.drfulmer.com
   β–Ό
nginx (TLS + reverse proxy)
   β”‚
   β”œβ”€β”€ /api/*  β†’  127.0.0.1:8003  (FastAPI via uvicorn)
   └── /*      β†’  127.0.0.1:3003  (Next.js via next start)
                       β”‚
                       └── /api/*  proxied to backend
                                  β”‚
                                  β–Ό
                       SQLite at data/josh3_learning.db
                                  β–²
                                  β”‚
                       MiniMax API (only when AI_LIVE=1)

Frontend file tree

frontend/
  app/
    layout.tsx          # global shell, metadata, viewport
    page.tsx            # home β€” daily dashboard
    checkin/page.tsx    # morning engagement check-in
    quest/page.tsx      # the quest loop (most complex page)
    store/page.tsx      # ring redemption
    parent/page.tsx     # parent dashboard + skip policy
    api-test/page.tsx   # API explorer
    wiki/page.tsx       # this page
    globals.css         # Tailwind + custom design tokens
  components/
    Character.tsx       # persona portraits + speech bubbles
    Decor.tsx           # SVG decoration library
    Shell.tsx           # top nav + footer + mascot
    DnDSequence.tsx     # drag-and-drop reading question
  lib/
    api.ts              # typed fetch wrapper
    types.ts            # shared TypeScript types
    user-context.tsx    # React Context for current user
  public/
    characters/         # 4 transparent PNGs Γ— 3 versions (v1/v2/v3)
    decor/              # bonus decorative PNGs
  next.config.js        # cache headers, reactCompiler
  tailwind.config.js    # design tokens
  package.json

Backend file tree

backend/
  app/
    main.py             # FastAPI app + router registration
    database.py         # SQLite connection + schema bootstrap
    models.py           # table definitions (SQLAlchemy Core)
    schemas.py          # Pydantic request/response shapes
    api/
      __init__.py       # router aggregator
      users.py          # CRUD + token deposit
      quests.py         # next / submit / hint / downgrade
      engagement.py     # engagement state, fatigue
      dashboard.py      # parent dashboard aggregates
      reports.py        # daily report generation + push
      devices.py        # gateway / MDM webhooks
      preferences.py    # skip policy endpoints
    content/
      math.py           # deterministic math generator
      reading.py        # reading passages (AI when enabled)
      reading_v2.py     # reading with A/B format support
      logic.py          # logic problems
      lore.py           # multi-step lore story generator
    adaptive.py         # quest selection, format A/B logic
    recovery.py         # cascading error recovery
    economy.py          # ring/token ledger
    daily_state.py      # engagement state per day
    rate_limit.py       # slowapi setup, X-Real-IP key
    skip_policy.py      # skip policy parser + enforcement
    pedagogy.py         # grade-level helpers
    ai.py               # MiniMax API client (httpx)
    config.py           # env loader
  daily_report.py       # cron-driven report entry point
  test_smoke.py         # basic smoke (legacy)
  test_smoke_v4.py      # phase 1-3 smoke (A/B, fatigue, recovery)
  test_smoke_skip.py    # phase 4 smoke (skip policy)
  requirements.txt
  start.sh / stop.sh

7. Database Schema

Eight tables. All in backend/app/database.py. SQL is hand-rolled; no migration framework. New columns are added with idempotent ALTER TABLE ... ADD COLUMN IF NOT EXISTS (SQLite β‰₯ 3.35) or wrapped in try/except for older versions.

Tables

users
idINTEGER PK
nameVARCHAR(80) NOT NULL
roleVARCHAR(20) NOT NULL
grade_levelINTEGER
token_balanceINTEGER NOT NULL (default 0)
created_atDATETIME NOT NULL
skip_modeVARCHAR(20) DEFAULT 'unlimited' (C14)
task_metrics
idINTEGER PK
user_idFK β†’ users.id
task_typeVARCHAR(40)
topicVARCHAR(120)
started_atDATETIME
completed_atDATETIME
time_on_task_secondsFLOAT
accuracyFLOAT (0.0–1.0)
attemptsINTEGER
engagement_scoreFLOAT
completedBOOLEAN
format_typeVARCHAR(8) (C8)
attempt_indexINTEGER (1/2/3)
parent_metric_idINTEGER (downgrade chain)
was_hintedBOOLEAN
was_downgradedBOOLEAN
bonus_ringsINTEGER DEFAULT 0 (C10)
skippedBOOLEAN DEFAULT 0 (C14)
token_ledger
idINTEGER PK
user_idFK β†’ users.id
amountINTEGER (signed)
reasonVARCHAR(120)
txn_idVARCHAR(80) UNIQUE
created_atDATETIME
daily_states
idINTEGER PK
user_idFK β†’ users.id
dayVARCHAR(10) (YYYY-MM-DD)
stateVARCHAR(16) (high/baseline/low)
sourceVARCHAR(16) (check-in/inferred/default)
checkin_scoreINTEGER
notesTEXT
created_atDATETIME
updated_atDATETIME
UNIQUE(user_id, day)
rewards_store
idINTEGER PK
user_idFK β†’ users.id
reward_idVARCHAR(40)
redeemed_atDATETIME
txn_idVARCHAR(80) UNIQUE
device_actions
idINTEGER PK
device_idVARCHAR(80)
actionVARCHAR(40)
payload_jsonTEXT
created_atDATETIME
report_deliveries
idINTEGER PK
user_idFK β†’ users.id
dateVARCHAR(10)
push_statusVARCHAR(20)
push_targetVARCHAR(200)
reportJSON
created_atDATETIME
quest_attempts
idINTEGER PK
user_idFK β†’ users.id
quest_idVARCHAR(80)
question_indexINTEGER
txn_idVARCHAR(80) UNIQUE (idempotency key)
attempt_indexINTEGER (1/2/3)
answerTEXT
correctBOOLEAN
created_atDATETIME

Notes on schema

  • Idempotency via txn_id. Every write that has a money/correctness side-effect takes atxn_id. Duplicate submits return the cached verdict instead of charging twice. This was added in C7 to handle network retries from the client.
  • attempt_index tracks the cascade: 1 = first try, 2 = after hint, 3 = after downgrade. Used to compute partial rewards.
  • parent_metric_id chains a downgraded question back to the original metric, so partial rewards can be applied correctly.

8. API Reference

All endpoints are under /api/. Auth is implicit (single-tenant; the user_id in the query/body identifies the caller). Rate-limited per real client IP via X-Real-IP (set by nginx, see Β§14).

Users

MethodPathPurpose
POST/api/usersCreate user. {name, role, grade_level}
GET/api/users/{id}Fetch user
POST/api/users/{id}/tokens/depositAdmin deposit. {amount, reason, txn_id}
GET/api/users/{id}/tokens/balanceCurrent balance
GET/api/users/{id}/statsAggregate stats
GET/api/users/{id}/streakCurrent daily streak

Quests

MethodPathPurpose
GET/api/quests/next?user_id=N&format=mc|dndGet next quest (adaptive selection)
POST/api/quests/submitSubmit answer. {user_id, quest, answer, txn_id, attempt_index, was_hinted, was_downgraded, skipped?, parent_metric_id?}
POST/api/quests/hintRequest a hint. {user_id, quest, persona}
POST/api/quests/downgradeRequest a simpler version. {user_id, quest}
POST/api/quests/lore-doneAward lore completion bonus. {user_id, quest, txn_id}
POST/api/quests/skipSkip the quest (gated by skip policy)

Engagement & state

MethodPathPurpose
GET/api/engagement/{user_id}Current engagement score + recent metrics
GET/api/fatigue?user_id=NFatigue heuristic (boolean + samples)
POST/api/checkinSet today's engagement state. {user_id, state, score?}
GET/api/daily-state?user_id=NToday's state record

Preferences

MethodPathPurpose
GET/api/preferences/skip?user_id=NGet skip policy view
PUT/api/preferences/skipUpdate mode. {user_id, mode (off/unlimited/adaptive:N)}

Dashboard & reports

MethodPathPurpose
GET/api/dashboard/parent?user_id=NParent dashboard data
GET/api/reports/{user_id}?date=YYYY-MM-DDGet a specific day's report
GET/api/reports/{user_id}/recentList recent reports
POST/api/reports/{user_id}/pushTrigger report push to webhook

Devices & gateway

MethodPathPurpose
POST/api/devices/{device_id}/actionsRecord a device action
GET/api/devices/{device_id}/actionsList recent actions
POST/api/webhooks/dnsDNS webhook receiver (configurable target)
POST/api/webhooks/mdmMDM webhook receiver

Store

MethodPathPurpose
GET/api/store/itemsAvailable rewards
POST/api/store/redeemRedeem an item. {user_id, reward_id, txn_id}
GET/api/store/history?user_id=NRedemption history

System

MethodPathPurpose
GET/api/healthLiveness. Returns 200 with version info.
GET/api/versionBuild + commit info

9. The Quest Loop

The core gameplay loop. Everything else (store, parent dashboard, daily report) is downstream of how a single quest feels.

Quest types

  • math β€” generated by content/math.py, fully deterministic, multiple choice
  • reading β€” generated by content/reading_v2.py, A/B between MC and DnD
  • logic β€” generated by content/logic.py, deterministic, multiple choice
  • lore β€” multi-step story; each step is itself a math/reading/logic problem

The loop, in 6 steps

  1. Pick: GET /api/quests/next returns the next quest. The selector in adaptive.py considers engagement state, recent accuracy, format A/B history, and topic coverage. Format (mc / dnd) can be overridden via ?format=.
  2. Render: the front-end shows the quest with persona intro, the question, and the choices (or DnD slots). Choices are shuffled client-side via a useMemo inapp/quest/page.tsx so the order stays stable across re-renders.
  3. Submit: POST /api/quests/submit with the answer. The backend scores it, writes atask_metrics row, updates the token ledger atomically, and returns a QuestVerdict with rings awarded, correct answer, and a cascade object if the answer was wrong.
  4. Verdict: the front-end shows a celebration screen. For correct answers: bouncing Sonic, the bonus animation if applicable, +rings. For wrong answers: a "Good try!" with Tails, the correct answer revealed, and a hint button.
  5. Cascade (if wrong): see Β§11. The student can request a hint (attempt 2), an easier question (attempt 3), or skip (if the parent allows it).
  6. Next: the student clicks Next Quest. C19 removed the 5-second auto-advance; the verdict screen waits indefinitely for the click.

Bonus animation

A subset of correct answers pay a 5Γ— ring bonus. The trigger is a variable-ratio schedule: each correct answer has a small probability of triggering the bonus, with one guaranteed bonus every N answers (whichever comes first). The 2.5-second setBonusFlash animation shows a giantStarBurst behind the verdict and aBONUS! x5 chip below it. See Β§10 for the rationale (variable-ratio is the most reliable engagement lever we have).

10. The Adaptive System

The system adapts in three ways: per-quest (format selection), per-day (engagement state), and per-session (fatigue detection).

Per-quest: A/B reading format

Reading comprehension has two formats: MC (multiple choice) and DnD (drag-and-drop sentence ordering). The selector in adaptive.py tracks recent accuracy per format and biases toward the one where Joshua is succeeding. The student can also override the format via the URL (?format=mc or ?format=dnd) or the in-quest MC/DnD toggle in the header.

Per-day: engagement state

Each day starts with a check-in. Joshua picks one of five buttons: Sonic (high), Mario, Luigi, Tails (low), Luigi-again (low). This sets the daily_state row for today. The state drives:

  • Which persona is the home-page mascot
  • Whether lore quests unlock (only on high)
  • Quest difficulty bias (low state β†’ easier problems)
  • The "Way past cool" voice at verdict time

If Joshua skips the check-in, the state defaults to baseline with a note that it was inferred. The check-in button appears in the nav until he does it for the day.

Per-session: fatigue detection

A rolling window of the last few attempts. If the accuracy drops below a threshold, the FatigueBanner component renders a "Time for a break?" card with a moon icon. The banner is purely informational β€” it doesn't block anything. It's the system telling him (and the parent watching) that this might not be a good moment to push.

Variable-ratio reinforcement

Each correct answer rolls a small probability of a 5Γ— ring bonus (the variable-ratio part). We also guarantee one bonus every N answers so he never goes too long without a surprise. This was added in C10 after observing that consistent flat rewards led to engagement decay; the bonus moments are the highest-engagement events in the analytics.

11. Cascading Error Recovery

A wrong answer is not a failure β€” it's the first step of a three-attempt cascade. The student never loses rings for getting it wrong; they only lose the opportunity for the full reward.

Attempt 1

Standard quest. Submit a wrong answer β†’ verdict screen shows the correct answer, Tails as the mascot, and a "Get a hint from {persona}" button (cascade.action === "hint_available"). No rings lost, no progress lost.

Attempt 2 (hint)

Click "Get a hint from Sonic" β†’ backend returns a short hint tailored to the question (AI in live mode, deterministic in stub). The front-end clears the verdict and goes back to the active quest view where the hint is now shown above the choices. The student re-picks with the hint in mind.

Bug fixed in C19: previously, the hint fetch succeeded but the verdict screen stayed up because the hint panel only renders in the active-quest branch. The fix was setVerdict(null) + setPicked(null) inside askHint().

Attempt 3 (downgrade)

If the hint didn't help, the cascade shows "Make it easier" (cascade.action === "downgrade_available"). Clicking it calls /api/quests/downgrade, which returns a simpler version of the same concept. The new quest replaces the current one and the student gets one more attempt at full reward.

Partial reward

If a student eventually answers correctly after a downgrade, they receive a partial reward (50% of the rings) plus a "✨ Partial reward earned" chip on the verdict screen. The hint-and-downgrade path is recorded in task_metrics viawas_hinted and was_downgraded flags, with parent_metric_id linking the downgraded attempt back to the original.

12. The Lore System

Lore is the long-form reward. Instead of a single question, the student gets a multi-step story (typically 3–5 steps). Each step is a math/reading/logic problem; correct answers unlock a paragraph of narrative that builds a Sonic/Mario-flavored story.

When lore unlocks

  • Only in high engagement state
  • Only after accuracy has been high for the session
  • The selector in content/lore.py decides when to serve one

The lore flow

  1. GET /api/quests/next returns a quest with type: "lore", payload.total_steps: N, and payload.steps: [step1, step2, ...].
  2. The front-end initializes loreProgress= {step: 0, unlocks: [], done: false, txnIds: []}.
  3. Each step is rendered with the existing math/reading/logic UI. Submitting a correct answer pushes thelore_unlock string into unlocks.
  4. The "Next Step" / "Skip ahead" button callsloreAdvance() (NOT loadNext() β€” see C19 fix below) to move to step N+1 within the same quest.
  5. On the final step, a correct answer triggers a completion bonus via /api/quests/lore-done. The verdict screen changes to "πŸŽ‰ Lore complete!" with the full unlocked story visible.

C19 bug: lore crash

Previously, the "Next Step" button called loadNext(), which set loreProgress = null while keeping the old quest in state. LoreRunner would then try to render with progress.step on a null progress and crash with a client-side exception. Two other problems were also fixed at the same time:

  • The step's choices were re-shuffled on every re-render, so the highlighted "picked" button would visibly jump to a new position right after the student clicked. Now memoized via useMemo.
  • Added a if (!loreProgress) return <p>Loading story...</p> safety check in the main quest render for the brief moment between loadNext setting it to null and the new quest arriving.

13. Skip Policy

Skip is the third option in the cascade: give up on the current quest, no rings lost, no penalty. The parent controls whether skip is available and how many times per day Joshua can use it.

Three modes

  • off β€” no skip button. Period. The parent has decided this is not an option.
  • unlimited (default) β€” skip is always available, no daily cap. Used during onboarding and high-energy days.
  • adaptive:N β€” skip is available up to N times per UTC day, then the button shows "No skips left today" and is disabled. N must be 1–99.

Where it lives

  • Storage: users.skip_mode VARCHAR(20) DEFAULT 'unlimited' (C14 migration, idempotent ALTER TABLE).
  • Parser: backend/app/skip_policy.py is the single source of truth. Both the API endpoints and the enforcement use it.
  • API: GET /api/preferences/skip returns the view (mode, limit, used_today, remaining_today, can_skip). PUT /api/preferences/skip updates the mode. Rejecting invalid modes (e.g. adaptive:0, adaptive:abc) returns 400.
  • Front-end: the SkipButton component in app/quest/page.tsx hides itself entirely when the mode is off; disables itself when adaptive:N is exhausted. The label shows the remaining count: "Skip this one (2 left today)".

Behavior on skip

Submitting a skip is a regular POST /api/quests/submit with skipped: true and answer: "". The backend records atask_metrics row with skipped: 1 and accuracy: 0, returns skipped: true in the verdict, and charges 0 rings. If the policy says no, the request returns 403 with a friendly detail like"Daily skip limit reached β€” your parent set this".

Why this design

Before C14, skip was a free-for-all. Parents (Bach) wanted a way to set expectations: some days unlimited is fine, some days we want him to push through 3–4 misses before giving up. A string-encoded policy with a daily counter is simple, single-tenant, and good enough.

14. Server Infrastructure

The colo server (current)

Single VPS, the production home:

  • Host: bfulmer@colo (107.175.150.86)
  • Specs: Xeon E3-1240, 31GB RAM, 458GB disk, Ubuntu 24.04
  • Public domain: https://josh3.drfulmer.com (Let's Encrypt, valid 89 days at last check, auto-renew via certbot)
  • Web: nginx 1.24 (TLS + reverse proxy)
  • App: systemd josh3-backend.service (uvicorn on 127.0.0.1:8003) and josh3-frontend.service (next start on 127.0.0.1:3003)
  • DB: SQLite at /home/bfulmer/josh3-learning/data/josh3_learning.db
  • Backups: cron at 03:00 UTC, 7-day rotation, data/backups/josh3_learning_YYYYMMDD_HHMMSS.db
  • Healthchecks: cron every 5 minutes, curl -fsS https://josh3.drfulmer.com/api/health

The old josh-vps (archived)

Previous production server. Services stopped, project files preserved at /home/bfulmer/josh3-learning/ on the old host as a fallback. Do not deploy to it. If a future contributor finds the old josh-vps has services running, stop them β€” they're stale.

The sibling app (separate)

https://josh2.drfulmer.com is a prototype app, also on colo, on ports 8002/3002, with its own systemd services. It is unrelated to this codebase and must remain untouched. Mentioned here so a new contributor doesn't confuse the two.

Systemd units

Both units are in deploy/josh-*.service and installed at /etc/systemd/system/. They run as User=bfulmer with ProtectSystem=full and ProtectHome=read-only, plus explicit ReadWritePaths=/home/bfulmer/josh3-learning/data /home/bfulmer/josh3-learning/logs. The DB and log dir are the only writable paths.

The ReadWritePaths bug: this was an early systemd misconfiguration that made the DB read-only and caused silent write failures. Documented in C15. The fix is in the deployed unit files; if you ever rewrite them, make sure ReadWritePaths is present.

nginx vhost

deploy/josh3.drfulmer.com.ssl.conf is the active vhost. It sets X-Real-IP and X-Forwarded-For from the real client (so the rate limiter in slowapi keys on real client IP, not localhost loopback), and proxies /api/* to the backend and the rest to the frontend.

Crontab (bfulmer@colo)

0 3 * * *  /home/bfulmer/josh3-learning/deploy/backup-db.sh
*/5 * * * *  /home/bfulmer/josh3-learning/deploy/healthcheck.sh

15. Development Workflow

The repo

  • Local: /Users/bfulmer/.minimax-agent/projects/josh3-learning
  • Remote: https://github.com/bachmanfulmer/josh3-learning (private)
  • Colo: bfulmer@colo:/home/bfulmer/josh3-learning (a fresh clone, tracks origin/main)

The deploy flow

# 1. Make changes locally
cd /Users/bfulmer/.minimax-agent/projects/josh3-learning
# ... edit files ...

# 2. Pre-deploy sanity check (matches what colo will run)
bash deploy/check.sh
# should print "ready to deploy"

# 3. Commit + push
git add -p
git commit -m "..."
git push

# 4. Deploy on colo (one command)
ssh colo "bash /home/bfulmer/josh3-learning/deploy/pull-and-restart.sh"

# That script does:
#   git pull --ff-only
#   bash deploy/check.sh
#   npm run build     (only if frontend source changed)
#   systemctl restart josh3-backend josh3-frontend
#   curl /api/health
# and exits non-zero if any step fails.

Local environment

Local doesn't have a running backend; the front-end proxies /api/* to http://127.0.0.1:8003 via Next.js rewrites in next.config.js. To develop locally, either:

  • Run the backend on colo and tunnel 8003 to your local machine: ssh -L 8003:127.0.0.1:8003 colo
  • Or copy backend/.env from colo (chmod 600!), set up the venv locally, and run PYTHONPATH=backend backend/.venv/bin/uvicorn app.main:app

What to commit, what not to

  • Always: code, config, scripts, PROGRESS.md, this wiki, character PNGs (small).
  • Never: .env, *.db, node_modules/, .next/, .venv/, logs/, *.archived (dead code), next-env.d.ts (auto-regenerated).
  • Sometimes: visual_qc screenshots (they're useful as evidence but bloat the repo; we currently commit them, but they could be moved to a separate artifacts repo if size becomes a problem).

16. Test Suite

Three smoke tests in backend/

All three require the backend to be running on127.0.0.1:8003. They create their own throwaway user (named with a timestamp) so they don't pollute Joshua's data.

test_smoke_v4.py β€” phase 1-3 (A/B, fatigue, recovery)

Hits /api/quests/next with explicit format overrides, submits a mix of right and wrong answers, triggers the hint cascade, exercises the fatigue banner, and verifies the bonus animation path. Run with: PYTHONPATH=backend JOSH_RATE_LIMIT_DISABLED=1 backend/.venv/bin/python backend/test_smoke_v4.py

test_smoke_skip.py β€” phase 4 (skip policy)

Tests the three modes (off, unlimited, adaptive:N), verifies invalid modes are rejected, exhausts an adaptive budget, confirms the lockout detail, and confirms non-skip submits still work. Run with: PYTHONPATH=backend JOSH_RATE_LIMIT_DISABLED=1 backend/.venv/bin/python backend/test_smoke_skip.py

test_smoke.py β€” legacy (don't rely on)

An older smoke that predates A/B and the cascade. Kept for reference but test_smoke_v4.py is the canonical suite. Don't add new tests here.

End-to-end tests (Playwright)

These live in /tmp/ on colo and run against the live site. They're written as ad-hoc verification scripts, not a maintained suite. Useful as references for "how do I exercise this flow?".

  • /tmp/test_e2e.py β€” happy-path quest β†’ submit β†’ verdict β†’ next
  • /tmp/test_c19_fixes.py β€” the three C19 bug fixes (auto-advance, hint, lore crash)
  • /tmp/test_c19_lore.py β€” lore multi-step (gated by engagement state)
  • /tmp/test_rate_limit.py β€” confirms slowapi 30/min write, 300/min read
  • /tmp/snap_v6*.py β€” visual QC scripts for the character transparency work

Pre-deploy check (deploy/check.sh)

The canonical "is this safe to push?" check. Runs locally AND on colo (the pull-and-restart script runs it as gate). It does:

  1. Parse all backend .py files
  2. Run tsc --noEmit on the front-end
  3. Confirm backend/.env is present with the AI key
  4. Confirm the venv has the required packages
  5. Run test_smoke_v4.py
  6. Run test_smoke_skip.py
  7. Hit /api/health from both localhost and the public URL

Exits 0 only if all pass. Outputs ready to deploy on success, DO NOT DEPLOY on failure.

17. Checkpoint History

Each checkpoint was a coherent unit of work β€” usually a feature, sometimes a fix, occasionally a migration. PROGRESS.md has the detailed changelog; this is the executive summary.

C1Initial deploy

FastAPI + SQLite + Next.js + HTTPS, real AI, single-tenant on josh.fulmer.us (later josh3.drfulmer.com). Domain + nginx + certbot.

C2Content engine

math / reading / logic generators. Deterministic, offline (no AI in default mode).

C3Quest loop

next / submit / persona feedback. The first end-to-end playable quest.

C4Device gateway

DNS + MDM webhooks for parent-controlled device management. Optional; not on the hot path.

C5Daily reports

Aggregated end-of-day stats: minutes, accuracy, rings earned, top topics. Push to webhook.

C6Frontend

home / quest / store / parent / api-test pages. The first usable UI.

C7DailyState, RewardsStore, atomic tokens, React Context

Engagement state per day, ring redemption, idempotent token ledger via txn_id, math-gated lore, async gateway, token velocity. Big infra checkpoint.

C8A/B reading comprehension

MC vs DnD. Two formats, adaptive selector, bias toward whichever is succeeding.

C9Cascading error recovery

Hint, downgrade, partial reward. The three-attempt flow.

C10Fatigue + variable-ratio reinforcement

Fatigue banner, 5Γ— bonus animation. The single biggest engagement lever in the system.

C11Sonic / Mario aesthetic

Character PNGs, themed UI, animations, decor SVG library. The system became something Joshua actually wanted to open.

C12Visual QC + mobile quest crash

Critical useMemo Rules-of-Hooks fix in app/quest/page.tsx. The page crashed on mobile because hooks were called after a conditional return.

C13UX sweep + polish

Title fix, format toggle, picked-answer visual, correct-answer reveal, DnD Pointer Events, auto-advance, store celebration.

C14Parent-configurable skip policy

users.skip_mode VARCHAR(20) DEFAULT 'unlimited'. The parent dashboard gets a radio for off / unlimited / adaptive:N. 25/25 smoke pass.

C15Migration to colo

Old josh-vps archived, fresh venv on colo, nginx vhost + Let's Encrypt, systemd services, ReadWritePaths bug fix.

C16Next 16 upgrade

Next 16.3.1 with Turbopack default. Build time 7.3s, 8/8 static pages. crontab for backup/healthcheck.

C17Hardening polish

React 19.2.8, slowapi rate limits (30/min write, 300/min read, X-Real-IP keyed), nosniff everywhere, max-age=86400 on art, deploy/check.sh pre-deploy validator.

C18React Compiler + transparent character art

reactCompiler: true + babel-plugin-react-compiler@1.0.0. Character.tsx polish: radial-gradient plate, 3px border, gold ring accent, alpha-aware drop-shadow. Initial attempt at transparent PNGs with PIL bg_to_alpha.py.

C18.1Transparent character fix (flood-fill)

Synthesized JPEGs had gray+white checkered backgrounds. Three iterations of bg_to_alpha_*.py before edge-seeded 4-connected flood fill (v4) produced clean 45% transparent / 53% opaque output. Cache bust via filename rename v1β†’v2β†’v3. Cached art 1y immutable was a footgun, reduced to 24h.

C19Three quest-page bug fixes

(1) Removed 5s auto-advance, click-to-advance. (2) Hint now visible after clicking 'Get a hint from Sonic' (setVerdict(null) inside askHint). (3) Lore multi-step no longer crashes (new loreAdvance() + useMemo for shuffled choices + null-progress safety check).

C19.1Verdict speech bubble + <think> leak

max-w-xs (320px) bubble inside a 992px outer card felt cut off. New wide prop on CharacterWithBubble: flex-1 min-w-0 sm:max-w-2xl, text-base, flex-col on mobile. Also fixed a think-stripper fallback that was returning the LLM's raw reasoning when the entire feedback was a think block.

C20Git version control

Private GitHub repo bachmanfulmer/josh3-learning, SSH deploy key on colo, deploy/pull-and-restart.sh for one-command deploys. Pre-git baseline tagged pre-git-baseline. Also recreated the missing backend/test_smoke_skip.py that deploy/check.sh referenced but was never committed.

18. Decisions & Trade-offs

Decisions a future contributor is most likely to question. Each entry has: what we chose, what the alternative was, and why we chose it.

Why SQLite, not Postgres?

Single tenant, one user, low write volume. Postgres would add a connection pool, a separate process to manage, and a backup story. SQLite gives us atomic writes, a single file to back up, and a single process to monitor. The breakeven point is probably 10–20 concurrent students; we're at 1.

Why no auth?

Single tenant, single household. The URL is the auth. If we ever go multi-tenant, this is the first thing that needs revisiting.

Why no service worker / offline support?

Joshua is always on a connected device when he plays. The complexity of caching + invalidation isn't worth the offline mode for one kid on a tablet.

Why stub AI in production by default?

Determinism. With AI live, the same quest can produce different feedback on different days, which makes it hard to test, hard to debug, and hard to demonstrate to Joshua that he's making progress. Stub mode returns consistent feedback from a template. The AI is one env-var flip away when we want a more dynamic feel.

Why variable-ratio (random) bonus instead of fixed?

The single biggest engagement lever in the system. Fixed rewards lead to engagement decay; slot-machine-style rewards don't. Same reason casinos don't pay out on a fixed schedule.

Why 24h cache on character art (not 1y immutable)?

We burned a year of browser cache into a version that turned out to be wrong (C18.1). 24h is a reasonable upper bound that still benefits repeat visitors. Future character swaps roll out within a day.

Why React Compiler?

Performance. We don't manually memoize; the compiler does it for us. Means we can write idiomatic React and still get a fast quest page. Trade-off: slightly more opaque rendering; debugging can be harder when the compiler elides a re-render you expected.

Why Image next/image with v3 URLs (not SVG sprites)?

The characters are complex, full-color, photographic. SVG would be a different art direction entirely. The trade-off is raster images need explicit cache busting (which C18.1 made annoying). If we go to vector art in the future, sprites or inline SVG becomes viable.

Why one big page (this wiki), not multi-page docs?

Volume. A wiki with 20 sections would mean 20 routes and 20 nav entries. A single page with anchor links is faster to read end-to-end and easier to keep in sync. If contributors start linking to specific sections by URL, we'll split.

Why character files committed at multiple versions (v1, v2, v3)?

Cache busting forced URL changes, not file content changes. The v1 and v2 URLs still resolve to the v4 (final, correct) content on disk as a belt-and-suspenders. The local v1/v2 PNGs are essentially dead weight; they should be cleaned up in a future commit.

Why id-based rate limit keyed on X-Real-IP, not session?

There's no session (no auth). nginx sets X-Real-IP from the real client; slowapi keys on it. Means a misbehaving client can't bypass the limit by rotating user-ids. Means a household with two devices on the same network shares a budget, which is fine β€” Joshua is the only user.

19. Known Issues

Things that are still rough. Not bugs, not yet fixed. Documented so the next contributor doesn't trip on them.

Test isolation is fragile

The smoke tests create their own user per run, but share the SQLite database. If two smokes run concurrently, they can collide on the daily_state or skip counters. We don't currently run them in parallel. If we ever want CI on this, we'd need to either spin up an isolated DB per worker or rewrite the smokes to be user-id-keyed.

Character file versions

public/characters/ has the same images under three names (sonic.png, sonic-v2.png, sonic-v3.png). Two of the three are dead β€” the front-end only references v3. We left them in for the audit trail but they're 8MB of zero-value data. TODO: archive them.

Daily state without check-in

If Joshua never hits the check-in, his state is baseline by default, not low. That's a deliberate choice (we don't want to start him in the worst state), but it means days where he doesn't check in have slightly easier quests than they would otherwise. Probably fine; flagged here in case it ever matters.

Frontend doesn't validate AI stub content

The CharacterWithBubble's <think>...</think> stripper only handles complete blocks (both opening and closing tags). If the LLM emits an unclosed <think> (which it has done in production), the stripper leaves the text alone and the student sees the raw reasoning. We do also have a fallback that detects "no closing tag" via a separate path now, but the regex itself could be tighter. Not currently a problem in practice (C19.1).

Legacy backup on colo

/home/bfulmer/josh3-learning.legacy/ on colo is a 1.1GB snapshot of the project immediately before the C20 git conversion. It contains the working venv and node_modules, so it's a real rollback point. After we're confident the new deploy flow is stable, it can be deleted.

20. Future Plans

Things we'd like to do, in rough priority order. Not commitments β€” the right time to do any of these is when the system needs it, not before.

Near-term

  • Migrate DNS josh.drfulmer.us β†’ josh3.drfulmer.com. The old domain is still on the old josh-vps; the new colo serves the .com. Migrating the .us would let us decommission the old VPS entirely.
  • CI for deploy/check.sh. A GitHub Action that runs the check on every PR and posts a status check. Cheap insurance.
  • Trim dead files. Thepublic/characters/*-v1.png and*-v2.png files are no longer referenced. After a few weeks with no cache complaints, archive them and shrink the repo.
  • Upgrade remaining Next 14-era packages. There are a few stragglers in package.json that could move forward.

Medium-term

  • Reading with a real passage generator. Right now we use the AI in stub mode, which means reading passages are a small fixed set. With AI live, we get fresh passages every time, but they need careful curation. Could become a separate microservice.
  • Engagement inference. Right now the daily state is set by check-in or defaults to baseline. We could infer it from recent metrics (accuracy trend, time-of-day, session length). Check-in becomes optional.
  • Per-topic progress. We track total accuracy but not per-topic mastery. Would let us bias the quest selector toward weak topics. Needs a topic taxonomy intask_metrics.
  • Lore branching. Lore is linear now. Could branch on choices. Would be a big content effort.

Long-term (if Joshua wants to keep going)

  • Multi-tenant. Right now the app is hard-wired to one user. Going multi-tenant means auth, per-tenant DB schemas or row-level isolation, and a real user model. Probably 2–3 weeks of work. Not worth it for one kid.
  • Voice narration. Some questions could be read aloud. Useful when reading itself is the bottleneck. TTS is built into every browser; just a UI change.
  • Multi-student. Joshua's friends, cousins. Sharing the system would mean per-student data isolation, per-student skip policy, per-student engagement. Same shape as multi-tenant but smaller scope.

21. Quick Reference

Common commands

# SSH
ssh colo                                       # production server
ssh josh-vps                                   # OLD server (services stopped)

# Deploy
ssh colo "bash /home/bfulmer/josh3-learning/deploy/pull-and-restart.sh"

# Pre-deploy check
cd /home/bfulmer/.minimax-agent/projects/josh3-learning
bash deploy/check.sh                           # local version
ssh colo "cd /home/bfulmer/josh3-learning && bash deploy/check.sh"   # on colo

# Service management (colo)
sudo systemctl status josh3-backend josh3-frontend
sudo systemctl restart josh3-backend josh3-frontend
sudo journalctl -u josh3-backend -n 50 --no-pager
sudo journalctl -u josh3-frontend -n 50 --no-pager

# Database
sqlite3 /home/bfulmer/josh3-learning/data/josh3_learning.db
sqlite3 /home/bfulmer/josh3-learning/data/josh3_learning.db ".tables"

# Logs
tail -f /home/bfulmer/josh3-learning/logs/backend.log
tail -f /home/bfulmer/josh3-learning/logs/frontend.log

# Backups
ls -lt /home/bfulmer/josh3-learning/data/backups/ | head

Environment variables (backend .env)

JOSH_AI_API_KEY=...           # MiniMax API key (chmod 600)
JOSH_AI_BASE_URL=https://api.minimax.io/v1
JOSH_AI_MODEL=MiniMax-M3
AI_LIVE=0                     # 0 = stub, 1 = real model
JOSH_RATE_LIMIT_DISABLED=1    # for tests / dev only
JOSH_DAILY_REPORT_WEBHOOK=    # optional, for report push
JOSH_DNS_WEBHOOK=             # optional
JOSH_MDM_WEBHOOK=             # optional

File locations

  • backend/.env β€” secrets, chmod 600
  • backend/app/main.py β€” FastAPI app entry
  • backend/app/database.py β€” schema + connection
  • frontend/app/quest/page.tsx β€” most complex page
  • frontend/components/Character.tsx β€” persona UI
  • deploy/check.sh β€” pre-deploy validator
  • deploy/pull-and-restart.sh β€” colo deploy script
  • PROGRESS.md β€” checkpoint changelog
  • /wiki β€” this page

API at a glance

  • GET /api/health β€” liveness
  • GET /api/quests/next?user_id=N β€” get a quest
  • POST /api/quests/submit β€” submit answer
  • GET /api/preferences/skip?user_id=N β€” get skip policy
  • GET /api/dashboard/parent?user_id=N β€” parent dashboard

Ports & processes (colo)

  • 127.0.0.1:8003 β€” backend (uvicorn)
  • 127.0.0.1:3003 β€” frontend (next start)
  • 443 β€” nginx (public)
  • Backend journald: josh3-backend.service
  • Frontend journald: josh3-frontend.service

22. Glossary

Cascade
The three-attempt error recovery flow: try β†’ hint β†’ downgrade. Wrong answers never cost rings; the cascade gives the student a path to partial credit.
Check-in
The morning screen where Joshua picks a character mascot that sets his engagement state for the day. Required for lore to unlock.
Engagement state
Per-day classification: high, baseline, or low. Drives mascot, difficulty, and lore gating.
Fatigue
A heuristic that fires when recent accuracy drops. Shows a "Time for a break?" banner. Informational, not blocking.
Lore
Multi-step story quest. Each step is a math/reading/logic problem; correct answers unlock narrative paragraphs.
Persona
One of four characters (Sonic, Mario, Tails, Luigi) with stable voice and visual identity. Drives feedback, mascot, and verdict-screen character.
Plate
The colored circle behind a character image. Sonic = blue, Mario = red, Tails = orange, Luigi = green. Set via CSS in Character.tsx.
Ring
The reward token. Earned by answering questions; spent in the store. Atomically tracked in the token_ledger.
Skip
Opt out of the current quest with no penalty. Gated by the parent-configurable skip policy (off / unlimited / adaptive:N).
Variable-ratio
A reinforcement schedule where a reward fires after a random number of correct responses. Drives the 5Γ— bonus. The most reliable engagement lever in the system.
Verdict
The screen shown after submitting an answer. Correct: celebration + ring awarded. Wrong: correct answer revealed + cascade button.

23. Action Adventure Arcade

The Action Arcade (/arcade) introduces 9 interactive learning game modes calibrated to Joshua's special interests with zero-punitive recovery mechanics:

  • Monster & Mobius Arena: PokΓ©mon-style creature battle engine where reading comprehension powers standard attacks (30 DMG), speed math triggers critical hits (45 DMG), word spelling activates healing potions (+35 HP), and bonus questions catch wild monsters.
  • Pixel Art Color-by-Code: Solve multiplication facts and phonetic patterns to unlock color palette paints for retro pixel art canvases.
  • Sonic Word Dash: High-speed Green Hill runner where Joshua taps sight words and arithmetic facts to sprint through speed gates.
  • Sentence Railway Switcher: Assemble scrambled train carts in proper grammatical order to clear the railway junction.
  • Audio Balloon Pop: Web Speech API pronounces target spelling words; Joshua pops rising letter balloons in correct sequence.
  • Voxel Block Builder: Minecraft-style 12x12 creative voxel sandbox with "Spell-to-Mine" material crates.
  • Emerald Memory Match: Retro card flip matching for sight words, definitions, and multiplication pairs.
  • Joshua's Comic & Story Studio: Creative writing studio with character stickers, speech bubbles, and TTS narration.
  • Sonic 60-Second Ring Sprint: Rapid-fire mental math blitz for bonus gold rings against the clock.

24. Cross-Curricular Discovery

The Discovery Hub (/discovery) provides Grade 4 cross-curricular exposure anchored in core literacy and numeracy:

  • Tails Science Lab (NGSS): Physical, life, and earth science investigations with synchronized word-by-word TTS narration and literal textual evidence questions.
  • Mario Coordinate Map (CCSS 4.G & NCSS): (X, Y) Cartesian coordinate grid (0 to 5) navigation and compass cardinal direction challenges.
  • Toad's Cash Register (Life Skills): Tactile money drawer with clickable dollar bills and coins to count exact payment and change.
  • Clock & Elapsed Time Master (CCSS 4.MD): Digital and analog clock displays with real-world elapsed time word problems.
  • Time-Travel History Machine (NCSS): Drag-and-drop chronological timeline sequencing for major human inventions.

25. Cinema & Visual Learning

The Cinema Theater (/cinema) integrates high-interest animated video rewards and science micro-documentaries:

  • Curated, distraction-free embeds with closed captions and fullscreen mode (Sonic Mania cartoon shorts, PokΓ©mon battle clips, volcanic science).
  • Unlocked via daily 3-quest learning streaks or rented in the Gold Ring Store.

26. Guided Daily Journey

To eliminate cognitive overwhelm and choice paralysis for autistic learners, the homepage provides aSingle Golden Action Path:

  1. Step 1: Daily Check-In (30s): Selects daily companion mascot and calibrates session difficulty.
  2. Step 2: 3 Guided Learning Missions (10-15m): Math fact blitz, science discovery reading, and creative writing.
  3. Step 3: Victory Celebration & Free Play: Unlocks Cinema tickets, PokΓ©mon battles, and Gold Ring store rewards.

This wiki is generated as a static Next.js page at frontend/app/wiki/page.tsx. When you make a design decision, hit a non-obvious bug, or change infrastructure, add it here. This is the long-term memory of the project.

Built for Joshua Β· 2026-08-16