Real-life side quest app. Every user gets 5–8 personalised daily quests generated by a hybrid template + AI pipeline — no quest is ever hand-written per user, and the AI never invents quests from scratch.
┌─────────────────────┐ ┌──────────────────────────────────────────┐
│ Flutter app │ HTTPS │ Backend (Node + TypeScript + Fastify) │
│ iOS / Android / │ ──────► │ │
│ macOS / Win / Linux │ JSON │ ┌────────────┐ ┌─────────────────────┐ │
│ / Web │ │ │ Auth (JWT) │ │ Quest Generation │ │
└─────────────────────┘ │ └────────────┘ │ Service │ │
│ ┌────────────┐ │ select → fill → │ │
│ │ REST routes│ │ render → validate │ │
│ └────────────┘ │ → persist │ │
│ │ └──────────┬──────────┘ │
│ ▼ ▼ │
│ ┌──────────┐ ┌────────────────┐ │
│ │ Database │ │ Claude API │ │
│ │ (SQLite │ │ (variable fill,│ │
│ │ dev / PG │ │ structured │ │
│ │ prod) │ │ output) │ │
│ └──────────┘ └────────────────┘ │
└──────────────────────────────────────────┘
Why this stack
- Flutter — one codebase for iOS, Android, macOS, Windows, Linux and the website. Exactly the requirement.
- Node + TypeScript + Fastify — fast, typed, huge ecosystem; the Anthropic SDK is first-class here.
- SQLite (dev) → PostgreSQL (prod) — identical schema shipped for both (schema.sql, schema.postgres.sql). SQLite means
npm startjust works; Postgres is the scale path.
Four tables (see server/db/schema.postgres.sql for the production version):
| Table | Purpose |
|---|---|
users |
email + bcrypt password hash, XP, level, and the personalisation profile: interests (JSON array of categories), difficulty, optional location, optional age_range, timezone |
quest_templates |
the reusable catalogue: description pattern with {slots}, typed variables (description + example pool per slot), category, difficulty, est_minutes (5–60), requires_photo, optional age gate |
quests |
finalised per-user quests: rendered title/description, server-computed xp_reward, status (active/completed/skipped/expired), the filled variables (kept for repetition control), optional proof photo_path |
generation_runs |
one row per (user, date) — makes daily generation idempotent and records whether AI or fallback produced the batch |
Key design points:
- Quests are denormalised snapshots. Once generated, a quest's text never changes even if the template is later edited.
variablesis stored on each quest so the generator can tell the AI exactly which values (and titles) to avoid next time.quest_dateis the user's local date (computed from their timezone), so "daily" means their day.
server/src/
├── index.ts Fastify bootstrap, CORS, health
├── config.ts all tunables (quest counts, cooldowns, XP table, model)
├── db.ts SQLite open + schema + template seeding (upsert)
├── auth.ts signup/login (bcrypt), JWT issue/verify middleware
├── routes/
│ ├── auth.routes.ts POST /auth/signup, /auth/login
│ ├── user.routes.ts GET/PATCH /me, GET /categories
│ └── quest.routes.ts GET /quests/today, /quests/history,
│ POST /quests/:id/complete, /quests/:id/skip
├── services/
│ ├── questGenerator.ts THE quest generation service (selection → persist)
│ └── aiFiller.ts Claude structured-output call + deterministic fallback
└── seed/templates.ts ~35 templates across 12 categories
Generation is lazy + idempotent: the first GET /quests/today of a user's day
triggers generation; every later call returns the same rows. At scale you add a
pre-generation batch job (see §7) — the service function is the same either way.
Per user, per local day (questGenerator.ts):
- Load context — profile + last 14 days of quests (titles and filled variable values).
- Select templates (pure code, no AI):
- exclude templates the user did within the last 7 days (cooldown; auto-relaxes if the pool runs dry),
- exclude age-gated templates the profile doesn't satisfy,
- weighted random: interest-matching categories ×3, difficulty matching preference ×2 / adjacent ×1 / opposite ×0.25,
- diversity cap: max one quest per category per day,
- count scales with progression: 5 quests at level 1 → 6 at level 4 → 8 at level 10.
- Fill variables with AI — one Claude call per user fills all selected templates (cheaper and more coherent than per-quest calls). Falls back deterministically if the API is unavailable.
- Render + validate in code — pattern slots substituted; any quest with an unfilled slot is dropped; XP is computed server-side from difficulty + duration and never taken from the AI; duration/free/safe constraints live in the template, not the model.
- Persist in one transaction, plus a
generation_runsrow for idempotency.
Repetition control happens at two levels: template cooldown (code) and value/title
avoidance (the AI is shown recently_used per template and instructed to avoid it; the
fallback filter does the same against the example pool).
See aiFiller.ts. The call shape:
- System prompt (static, cached) — role ("quest writer for APEX"), the hard rules
(5–60 min, free, safe/legal, broad audience, no equipment), and style rules for titles
and values. It carries a
cache_control: {type: "ephemeral"}breakpoint, so after the first request the per-user cost is mostly the small dynamic payload (~90% cheaper on the cached portion). - User message (dynamic, JSON) —
profile(interests, difficulty, level, location, age range),templates(id, category, pattern, title hint, per-variable description + examples),recently_used(per-template values/titles to avoid). - Structured output —
output_config.formatwith a JSON schema, so the response is guaranteed-parseable:{quests: [{templateId, title, variables: [{name, value}]}]}. Variables are name/value pairs so one static schema covers every template (and a static schema hits the API's 24h schema-compilation cache). - Server-side guardrails after the call — unknown template ids dropped, missing variables backfilled by the fallback, titles truncated, XP/duration/photo flags always from the template, never the model.
Model & cost. Default is claude-opus-4-8 (set QUEST_MODEL to change). A
generation request is small — roughly 1–2K input tokens (mostly cached) and ~300 output
tokens. For thousands of users, run the nightly pre-generation through the Message
Batches API (same request shape, 50% price, results well within an overnight window).
What AI is not used for — inventing quests from nothing, setting rewards, deciding which categories a user gets, or anything safety-critical. Those are code.
| Method | Path | Auth | Description |
|---|---|---|---|
POST |
/auth/signup |
— | {email, password, displayName?} → {id, email, token} |
POST |
/auth/login |
— | {email, password} → {id, email, token} |
GET |
/me |
Bearer | profile, XP, level |
PATCH |
/me |
Bearer | update interests, difficulty, location, ageRange, timezone, displayName |
GET |
/categories |
— | the 12 quest categories |
GET |
/quests/today |
Bearer | today's quests (generates them on first call of the day) |
GET |
/quests/history |
Bearer | completed quests — the memory journal |
POST |
/quests/:id/complete |
Bearer | optional {photoBase64}; returns {xpAwarded, totalXp, level} |
POST |
/quests/:id/skip |
Bearer | mark skipped (no XP) |
GET |
/health |
— | liveness + whether AI is enabled |
Example quest object (matches the brief's example output):
{
"title": "Reflection Hunter",
"description": "Find and photograph three reflections in different places.",
"category": "Photography",
"difficulty": "Easy",
"xpReward": 60,
"estMinutes": 20,
"requiresPhoto": true,
"status": "active"
}12 categories: the 8 requested (Adventure, Photography, Fitness, Learning, Social, Creativity, Productivity, Food) plus Mindfulness, Nature, Kindness, Music. ~35 seed templates live in templates.ts; each has 2–6 example values per variable, so the catalogue already encodes thousands of distinct quests before the AI adds open-ended variety on top. Adding a template = adding one object to that file (it upserts into the DB on boot).
What changes as you grow — the service code stays the same:
- Database: swap SQLite for Postgres (
schema.postgres.sqlis ready); the queries are vanilla SQL. Indexes on(user_id, quest_date)and(user_id, status)keep the hot paths O(log n). - Pre-generation instead of lazy: a scheduled job per timezone bucket (cron at each
zone's midnight) calls
generateDailyQuestsfor active users, submitted as a Batches API job → 50% AI cost and zero generation latency at 8am peak. Lazy generation remains the safety net for users the job missed. - Idempotency under concurrency: the
generation_runsprimary key makes double generation impossible even if the cron and a lazy request race (first insert wins; in Postgres wrap withON CONFLICT DO NOTHING+ re-read). - Prompt caching: the static system prompt is shared across all users, so at volume nearly the whole prompt is a cache read (~0.1× input price).
- Cost envelope: ~1.5K input (cached after first) + ~300 output tokens per user-day.
At 10K users on the Batches API this is a few dollars a day; switch
QUEST_MODELif you want a different cost/quality point. - Stateless API: JWT auth means any number of server replicas behind a load balancer; the only shared state is the DB.
- Photos: dev stores uploads on disk; production should write to object storage
(S3/GCS/Supabase Storage) and store the URL in
photo_path— it's one function to swap inquest.routes.ts. - Degradation path: AI outage ≠ outage. The deterministic fallback keeps daily
quests flowing from template example pools;
generation_runs.sourcerecords which path produced each batch so you can monitor it.
| Rule | Where enforced |
|---|---|
| 5–60 minutes | template est_minutes constraint + prompt rule |
| No spending money | template design + prompt rule |
| Safe & legal | template design + prompt rule + curated example pools |
| Broad audience | prompt rule + optional min_age_range gating in code |
| Photo proof optional | requires_photo is encouragement; completion never requires a photo |
| XP integrity | computed server-side only |
| No recent repeats | template cooldown (code) + recently_used (AI) + example filtering (fallback) |