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.
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: #0F4DA8mario-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.0indevDependencies- No state library β React Context (
UserProvider) for user,useStatefor 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 in
backend/app/database.pywith idempotentCREATE TABLE IF NOT EXISTS. No Alembic. - Backups: cron-driven
backup-db.shat 03:00 UTC, 7-day rotation, kept indata/backups/
AI
- Provider: MiniMax at
https://api.minimax.io/v1, modelMiniMax-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 theAI_LIVEenv 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.jsonBackend 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.sh7. 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
| id | INTEGER PK |
| name | VARCHAR(80) NOT NULL |
| role | VARCHAR(20) NOT NULL |
| grade_level | INTEGER |
| token_balance | INTEGER NOT NULL (default 0) |
| created_at | DATETIME NOT NULL |
| skip_mode | VARCHAR(20) DEFAULT 'unlimited' (C14) |
| id | INTEGER PK |
| user_id | FK β users.id |
| task_type | VARCHAR(40) |
| topic | VARCHAR(120) |
| started_at | DATETIME |
| completed_at | DATETIME |
| time_on_task_seconds | FLOAT |
| accuracy | FLOAT (0.0β1.0) |
| attempts | INTEGER |
| engagement_score | FLOAT |
| completed | BOOLEAN |
| format_type | VARCHAR(8) (C8) |
| attempt_index | INTEGER (1/2/3) |
| parent_metric_id | INTEGER (downgrade chain) |
| was_hinted | BOOLEAN |
| was_downgraded | BOOLEAN |
| bonus_rings | INTEGER DEFAULT 0 (C10) |
| skipped | BOOLEAN DEFAULT 0 (C14) |
| id | INTEGER PK |
| user_id | FK β users.id |
| amount | INTEGER (signed) |
| reason | VARCHAR(120) |
| txn_id | VARCHAR(80) UNIQUE |
| created_at | DATETIME |
| id | INTEGER PK |
| user_id | FK β users.id |
| day | VARCHAR(10) (YYYY-MM-DD) |
| state | VARCHAR(16) (high/baseline/low) |
| source | VARCHAR(16) (check-in/inferred/default) |
| checkin_score | INTEGER |
| notes | TEXT |
| created_at | DATETIME |
| updated_at | DATETIME |
| UNIQUE | (user_id, day) |
| id | INTEGER PK |
| user_id | FK β users.id |
| reward_id | VARCHAR(40) |
| redeemed_at | DATETIME |
| txn_id | VARCHAR(80) UNIQUE |
| id | INTEGER PK |
| device_id | VARCHAR(80) |
| action | VARCHAR(40) |
| payload_json | TEXT |
| created_at | DATETIME |
| id | INTEGER PK |
| user_id | FK β users.id |
| date | VARCHAR(10) |
| push_status | VARCHAR(20) |
| push_target | VARCHAR(200) |
| report | JSON |
| created_at | DATETIME |
| id | INTEGER PK |
| user_id | FK β users.id |
| quest_id | VARCHAR(80) |
| question_index | INTEGER |
| txn_id | VARCHAR(80) UNIQUE (idempotency key) |
| attempt_index | INTEGER (1/2/3) |
| answer | TEXT |
| correct | BOOLEAN |
| created_at | DATETIME |
Notes on schema
- Idempotency via txn_id. Every write that has a money/correctness side-effect takes a
txn_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
| Method | Path | Purpose |
|---|---|---|
| POST | /api/users | Create user. {name, role, grade_level} |
| GET | /api/users/{id} | Fetch user |
| POST | /api/users/{id}/tokens/deposit | Admin deposit. {amount, reason, txn_id} |
| GET | /api/users/{id}/tokens/balance | Current balance |
| GET | /api/users/{id}/stats | Aggregate stats |
| GET | /api/users/{id}/streak | Current daily streak |
Quests
| Method | Path | Purpose |
|---|---|---|
| GET | /api/quests/next?user_id=N&format=mc|dnd | Get next quest (adaptive selection) |
| POST | /api/quests/submit | Submit answer. {user_id, quest, answer, txn_id, attempt_index, was_hinted, was_downgraded, skipped?, parent_metric_id?} |
| POST | /api/quests/hint | Request a hint. {user_id, quest, persona} |
| POST | /api/quests/downgrade | Request a simpler version. {user_id, quest} |
| POST | /api/quests/lore-done | Award lore completion bonus. {user_id, quest, txn_id} |
| POST | /api/quests/skip | Skip the quest (gated by skip policy) |
Engagement & state
| Method | Path | Purpose |
|---|---|---|
| GET | /api/engagement/{user_id} | Current engagement score + recent metrics |
| GET | /api/fatigue?user_id=N | Fatigue heuristic (boolean + samples) |
| POST | /api/checkin | Set today's engagement state. {user_id, state, score?} |
| GET | /api/daily-state?user_id=N | Today's state record |
Preferences
| Method | Path | Purpose |
|---|---|---|
| GET | /api/preferences/skip?user_id=N | Get skip policy view |
| PUT | /api/preferences/skip | Update mode. {user_id, mode (off/unlimited/adaptive:N)} |
Dashboard & reports
| Method | Path | Purpose |
|---|---|---|
| GET | /api/dashboard/parent?user_id=N | Parent dashboard data |
| GET | /api/reports/{user_id}?date=YYYY-MM-DD | Get a specific day's report |
| GET | /api/reports/{user_id}/recent | List recent reports |
| POST | /api/reports/{user_id}/push | Trigger report push to webhook |
Devices & gateway
| Method | Path | Purpose |
|---|---|---|
| POST | /api/devices/{device_id}/actions | Record a device action |
| GET | /api/devices/{device_id}/actions | List recent actions |
| POST | /api/webhooks/dns | DNS webhook receiver (configurable target) |
| POST | /api/webhooks/mdm | MDM webhook receiver |
Store
| Method | Path | Purpose |
|---|---|---|
| GET | /api/store/items | Available rewards |
| POST | /api/store/redeem | Redeem an item. {user_id, reward_id, txn_id} |
| GET | /api/store/history?user_id=N | Redemption history |
System
| Method | Path | Purpose |
|---|---|---|
| GET | /api/health | Liveness. Returns 200 with version info. |
| GET | /api/version | Build + 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 bycontent/math.py, fully deterministic, multiple choicereadingβ generated bycontent/reading_v2.py, A/B between MC and DnDlogicβ generated bycontent/logic.py, deterministic, multiple choiceloreβ multi-step story; each step is itself a math/reading/logic problem
The loop, in 6 steps
- Pick:
GET /api/quests/nextreturns the next quest. The selector inadaptive.pyconsiders engagement state, recent accuracy, format A/B history, and topic coverage. Format (mc/dnd) can be overridden via?format=. - 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
useMemoinapp/quest/page.tsxso the order stays stable across re-renders. - Submit:
POST /api/quests/submitwith the answer. The backend scores it, writes atask_metricsrow, updates the token ledger atomically, and returns aQuestVerdictwith rings awarded, correct answer, and acascadeobject if the answer was wrong. - 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.
- 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).
- 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
highengagement state - Only after accuracy has been high for the session
- The selector in
content/lore.pydecides when to serve one
The lore flow
GET /api/quests/nextreturns a quest withtype: "lore",payload.total_steps: N, andpayload.steps: [step1, step2, ...].- The front-end initializes
loreProgress={step: 0, unlocks: [], done: false, txnIds: []}. - Each step is rendered with the existing math/reading/logic UI. Submitting a correct answer pushes the
lore_unlockstring intounlocks. - The "Next Step" / "Skip ahead" button calls
loreAdvance()(NOTloadNext()β see C19 fix below) to move to step N+1 within the same quest. - 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 betweenloadNextsetting 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.Nmust be 1β99.
Where it lives
- Storage:
users.skip_mode VARCHAR(20) DEFAULT 'unlimited'(C14 migration, idempotent ALTER TABLE). - Parser:
backend/app/skip_policy.pyis the single source of truth. Both the API endpoints and the enforcement use it. - API:
GET /api/preferences/skipreturns the view (mode, limit, used_today, remaining_today, can_skip).PUT /api/preferences/skipupdates the mode. Rejecting invalid modes (e.g.adaptive:0,adaptive:abc) returns 400. - Front-end: the
SkipButtoncomponent inapp/quest/page.tsxhides itself entirely when the mode isoff; disables itself whenadaptive:Nis 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) andjosh3-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/.envfrom colo (chmod 600!), set up the venv locally, and runPYTHONPATH=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:
- Parse all backend
.pyfiles - Run
tsc --noEmiton the front-end - Confirm
backend/.envis present with the AI key - Confirm the venv has the required packages
- Run
test_smoke_v4.py - Run
test_smoke_skip.py - Hit
/api/healthfrom 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.
FastAPI + SQLite + Next.js + HTTPS, real AI, single-tenant on josh.fulmer.us (later josh3.drfulmer.com). Domain + nginx + certbot.
math / reading / logic generators. Deterministic, offline (no AI in default mode).
next / submit / persona feedback. The first end-to-end playable quest.
DNS + MDM webhooks for parent-controlled device management. Optional; not on the hot path.
Aggregated end-of-day stats: minutes, accuracy, rings earned, top topics. Push to webhook.
home / quest / store / parent / api-test pages. The first usable UI.
Engagement state per day, ring redemption, idempotent token ledger via txn_id, math-gated lore, async gateway, token velocity. Big infra checkpoint.
MC vs DnD. Two formats, adaptive selector, bias toward whichever is succeeding.
Hint, downgrade, partial reward. The three-attempt flow.
Fatigue banner, 5Γ bonus animation. The single biggest engagement lever in the system.
Character PNGs, themed UI, animations, decor SVG library. The system became something Joshua actually wanted to open.
Critical useMemo Rules-of-Hooks fix in app/quest/page.tsx. The page crashed on mobile because hooks were called after a conditional return.
Title fix, format toggle, picked-answer visual, correct-answer reveal, DnD Pointer Events, auto-advance, store celebration.
users.skip_mode VARCHAR(20) DEFAULT 'unlimited'. The parent dashboard gets a radio for off / unlimited / adaptive:N. 25/25 smoke pass.
Old josh-vps archived, fresh venv on colo, nginx vhost + Let's Encrypt, systemd services, ReadWritePaths bug fix.
Next 16.3.1 with Turbopack default. Build time 7.3s, 8/8 static pages. crontab for backup/healthcheck.
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.
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.
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.
(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).
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.
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.
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.
Single tenant, single household. The URL is the auth. If we ever go multi-tenant, this is the first thing that needs revisiting.
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.
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.
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.
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.
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.
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.
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.
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.
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. The
public/characters/*-v1.pngand*-v2.pngfiles 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.jsonthat 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 in
task_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 600backend/app/main.pyβ FastAPI app entrybackend/app/database.pyβ schema + connectionfrontend/app/quest/page.tsxβ most complex pagefrontend/components/Character.tsxβ persona UIdeploy/check.shβ pre-deploy validatordeploy/pull-and-restart.shβ colo deploy scriptPROGRESS.mdβ checkpoint changelog/wikiβ this page
API at a glance
GET /api/healthβ livenessGET /api/quests/next?user_id=Nβ get a questPOST /api/quests/submitβ submit answerGET /api/preferences/skip?user_id=Nβ get skip policyGET /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, orlow. 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:
- Step 1: Daily Check-In (30s): Selects daily companion mascot and calibrates session difficulty.
- Step 2: 3 Guided Learning Missions (10-15m): Math fact blitz, science discovery reading, and creative writing.
- Step 3: Victory Celebration & Free Play: Unlocks Cinema tickets, PokΓ©mon battles, and Gold Ring store rewards.