A complete brief for building a personalized, AI-powered quiz app for your own kids. Written so you can hand it to a coding agent (Claude Code, Codex, Cursor, whatever) and have it build the whole thing — or read it top-to-bottom yourself and understand every decision. It's based on a real app running in production for two kids (ages 5 and 10); the hard-won lessons here are the ones you can't get from a fresh spec.
Two ways:
- Hand it to your agent. Paste this whole file into Claude Code / Codex and say "Build this for my kids. Ask me for their names, ages, grade levels, and interests first, then go." The spec is detailed enough to execute against. It'll fill in your kids' specifics where this brief uses two placeholder kids — a "son" (10) and a "daughter" (5) — as examples.
This is a template, not a fixed build — make it yours. The two example kids are just that: examples. Tell your agent the real names (or keep them anonymous — your call), and how many kids you actually have. One kid, three kids, five kids — it's all the same engine, one config object per child. Their profiles will differ from the examples too: a 7th grader, a 4-year-old, a teenager brushing up for a test — swap in the real ages, grades, reading levels, and interests. The engine and the lessons don't change; only the per-kid config does. 2. Read it, then cherry-pick. The architecture and the lessons (§6) are the valuable part. The exact stack (React, Cloudflare) is swappable — the design decisions are what transfer.
The single most important thing in here is §3, the pivot: we started with "every question is an AI call" and ended up deleting the AI from most of the app. If you skip everything else, read that. It'll save you the two days it cost us to learn it.
A no-login, mobile-first web quiz where each kid taps a link and gets an endless stream of age-appropriate multiple-choice questions, with a per-kid save slot, difficulty levels, and a reward mini-game every few correct answers.
- One shared game engine, parameterized by a per-kid config object. You do NOT fork the
code per child. One route per kid (
/quiz/<kid1>,/quiz/<kid2>, ...) passes a different config into the same component. Works for one kid or five — adding a kid is a config file, not a rewrite. - No accounts, no install. Kid opens a URL on a phone/tablet and plays. Progress auto-saves to
localStorage. - The API key never touches the browser. A tiny serverless proxy holds the key and builds prompts server-side. The frontend is a thin client that sends parameters, not prompts.
- Free infrastructure. Static site host + a serverless function free tier covers a household's usage with room to spare.
- Frontend: Create React App (react-scripts 5), React, TypeScript, Tailwind, react-router v6.
- Proxy: a single Cloudflare Worker (
worker.js) + Cloudflare KV for question history. - Model: Claude Haiku (fast + cheap; more than enough for grade-school questions).
- Hosting: static site on any static host with git auto-deploy; the Worker deploys separately.
None of this is load-bearing. Next.js + a Vercel function + OpenAI would be a fine substitute. The shape is what matters: thin client → key-holding proxy → model, with history dedup in the proxy.
┌──────────────┐ POST {player, typeIndex, ┌───────────────┐ builds prompt, ┌─────────┐
│ React app │ difficulty, seed} │ Proxy Worker │ calls model │ Claude │
│ (thin client)│ ────────────────────────────▶ │ (holds key) │ ─────────────────▶ │ Haiku │
│ │ ◀──────────────────────────── │ │ ◀───────────────── │ │
└──────────────┘ question JSON └───────┬───────┘ question JSON └─────────┘
│ │
│ localStorage (L0): │ KV (question history, per kid):
│ score, streak, difficulty, etc. │ last 60 questions, for dedup
▼ ▼
survives refresh cross-device "don't repeat" memory
Why the proxy builds the prompt (not the frontend): it keeps the API key out of the browser bundle and keeps all the prompt logic — personas, per-type instructions, difficulty text, the "don't repeat these" history — in one server-side place. The frontend just sends four numbers: which kid, which question type, which difficulty, a variety seed.
Question-history dedup lives in the proxy, not the browser. After each successful question, the proxy appends the question text to a per-kid history list (rolling window of ~60) and injects that list into the next prompt as "do not repeat any of these." Store it server-side (we used a key-value store) so a kid who plays on a tablet in the morning and a phone at night still doesn't see repeats. Use a two-level cache — an in-memory copy per server instance (L1) backed by the persistent store (L2) — and write to the store after returning the response (fire-and-forget) so the store write never sits on the critical path. This detail only matters for the AI-backed question types — see §3, most types won't hit the model at all.
The config-object pattern (the core of the whole thing):
interface PlayerConfig {
playerName: string; // the kid's name, e.g. "Son"
localStorageKey: string; // "son-quiz-state" — separate save slot per kid
pageTitle: string; // "Son's Quiz 🎓"
questionTypes: string[]; // the rotation of question categories for this kid
typeInstructions: Record<string, string>; // per-type prompt guidance
difficultyConfig: Record<number, { label: string; desc?: string }>; // 1 or 3 tiers
personaPrompt: string; // who the AI is talking to, tone, interests
specialRules: string; // hard constraints for this age group
}Two routes, same engine:
<Route path="/quiz/son" element={<QuizGame config={SON_CONFIG} />} />
<Route path="/quiz/daughter" element={<QuizGame config={DAUGHTER_CONFIG} />} />The engine reads the config for: which question types to rotate through, how to scale the UI, which
difficulty tiers to expose, streak thresholds, and whether to show text-to-speech buttons. Do not
put a kid's name in a public URL — use /quiz/<name> under a section, not /<name> at the root,
and don't name the proxy after one child (kids-quiz-proxy, not <name>-quiz-proxy).
Every question — whether AI-generated or code-generated (§3) — returns the same shape. Design this first; everything else conforms to it:
{
"question": "What is 7 × 8?",
"emoji": "✖️",
"type": "math_multiplication",
"answers": ["54", "56", "48", "63"],
"correct": 1,
"praise": "Yes! 7 × 8 = 56 — like 7 boxes of 8 crayons! 🖍️",
"hint": "Think of 7 groups of 8.",
"wrongContext": [
"54 is 6 × 9 — close, but different numbers!",
"",
"48 is 6 × 8 — right 8, but you used 6 instead of 7.",
"63 is 7 × 9 — right 7, but you used 9 instead of 8."
]
}- Exactly 4 answers, all distinct; exactly one
correctindex in[0,3]. wrongContextis length 4:""at the correct index, a one-to-two-sentence why-this-is-a-common- mistake explanation at each wrong index. This is the pedagogical heart of the app — kids learn more from understanding why their wrong guess was tempting than from the right answer alone.
We built this exactly as the plan said: a smart proxy, a two-level cache, "every question is one model call." The plan even had a "do not simplify the cache" warning. Within 48 hours of shipping it, the most important design move was to delete the AI from most of the app.
Why: the 5-year-old got 🦋🦋🦋🦋 How many butterflies? with answer choices [2, 3, 5, 1]. The
correct answer, 4, wasn't even an option — the model miscounted the emojis it had itself chosen.
Then: What color is 🌙? → the "correct" answer was "white," but the moon renders yellow/gold on
every phone. The whole family picked yellow and got marked wrong.
The lesson: an LLM is the wrong tool for deterministic domains. Counting, arithmetic, "which shape has 3 sides," color identification — these have exactly one right answer computable in code, and the LLM adds latency, cost, and a nonzero hallucination rate for zero benefit. So we split the question types into two pipelines:
| Pipeline | Use for | How it works |
|---|---|---|
| Deterministic (code) | Anything with one computable answer: counting, arithmetic, shapes, colors, patterns, letters, opposites, rhyming, multiplication, division, measurement/time | Seeded RNG + hand-authored content banks generate the question, the correct answer, and plausible distractors — in code, on the server, zero model calls, zero cost, <1ms, 100% correct |
| AI-backed (LLM) | Open-ended stuff a human writes better: reading comprehension, vocabulary, science facts, geography, mythology, grammar, trivia | Model call with persona + type instructions + difficulty + "don't repeat" history |
Concretely, for our two kids: the 5-year-old's entire quiz became deterministic — zero model calls. The 10-year-old's 5 math types became deterministic; his 7 open-ended types stayed on the model.
Design implication for your agent: build the deterministic generators as pure functions of
(difficulty, seed) returning the same JSON contract as the AI path. The proxy dispatches: if the
requested type is deterministic, generate in code and return immediately; otherwise call the model.
The frontend can't tell the difference — same contract either way.
How to write a deterministic generator (the pattern that matters):
- A
seededRandom(seed)PRNG so output is reproducible and testable. - A content bank for the domain (e.g. an emoji→color map, 100 word-problem scenarios, a letter→word→emoji bank). Go big — hundreds of entries — so questions don't feel repetitive.
- Compute the correct answer, then a
generateDistractors(correct, ...)helper that produces 3 plausible-but-wrong options (e.g. off-by-one counts, adjacent multiplication facts), shuffled with the true answer. - Invariant: the true answer is always one of the four choices. This is the butterfly bug, encoded as a rule that can't be violated. Add a test that fails loudly if it ever is.
Same engine, deliberately different everything-else per kid. This table is the template — fill in your own children. Ours were a 5-year-old (pre-reader, starting Kindergarten) and a 10-year-old (Grade 4):
| Dimension | Younger kid (5) | Older kid (10) |
|---|---|---|
| Question types | letter-recognition, counting, colors, shapes, animals, opposites, rhyming, beginning-sound, simple-math, patterns | multiplication, word problems, animal science, vocab, patterns, space, division, geography, mythology, measurement/time, grammar, trivia |
| Difficulty tiers | 1 (hide the selector when there's only one) | 3 (Normal / Harder / Challenge) |
| Reading load | Emoji-first — she can't read fluently. Answers are single words, numbers, or single emojis. Never a full-sentence answer. | Normal text; richer distractors and explanations |
| Content rules | Positive phrasing only (never "which is NOT"); no clocks/money; no academic verbs ("calculate", "identify"); one idea per question | No fractions; clean whole-number division (÷2–9, <100); 1–2 step word problems; culturally relevant facts |
| Persona | Playful, celebration dialed up, framed as "already ahead" so the AI isn't condescending | Warm host leaning into his interests (LEGO/space/mythology); "wow facts" |
| Praise style | Specific, not generic. "Yes! 🐄 Cows say MOO!" — never "Good job!" | Explains why it's correct in one enthusiastic sentence |
UI scaling (isKid flag) |
Bigger fonts (clamp(1.4rem,5vw,1.8rem)), 60px tap targets, dark-contrast pill behind emoji clusters, category emoji removed (confusing) |
Standard fonts, 52px tap targets, category emoji kept |
| Text-to-speech | 🔊 speaker buttons on the question and each answer (she can't read) | None |
| Streak reward threshold | Every 3 correct (shorter attention span → faster reinforcement) | Every 5 correct (sustain focus, delayed gratification) |
The personaPrompt + specialRules fields carry the tone and hard constraints into every AI
prompt. Example of the younger kid's specialRules (verbatim from ours — it works):
- Questions must be answerable by a child who cannot yet read fluently. Rely on emojis, not sentences.
- Answer choices must be short: single words, single numbers, or single emojis. Never a full sentence.
- Never ask multi-step questions. One idea per question only.
- Never use academic instruction language ("calculate", "identify", "determine").
- Never ask about time (clocks, calendars) or money.
- Never use negative phrasing ("Which one is NOT a...") — always ask positively.
- Praise must be warm and specific.
- Streak charging bar. A bar that fills as the kid answers correctly in a row; at the threshold (3 or 5 per kid) it "banks" a reward token and fires a mini-game. We started with a popup, hated how it interrupted, and moved to an inline charging bar + a ⚙️ settings menu.
- Reward mini-games. A rotating set of quick, silly games (we shipped 5: Star Pop, Whack-a-Mole,
Memory Match, Catch It!, Bubble Pop) that play for ~15 seconds as a "brain break," then drop back
into the quiz. Same games for both kids — the younger one feels grown-up playing the same thing.
Build a tiny
GameDefregistry so adding a game is a data entry, not a refactor. A?game=<id>URL backdoor is invaluable for testing them without grinding a streak. - Themes that rotate on "Next Question" (not on answer — rotating on answer is jarring).
- localStorage auto-save after every answer: score, streak, best streak, difficulty, category scores. Restore on mount; on corrupt/missing state, reset cleanly. No save/load buttons — it's invisible. Keep one "New Day" reset that also clears storage (doubles as a cache-buster, see §6).
Note: "fun" has no automated test. If you're building with an agent, these are the parts to eyeball yourself with a real kid — the deterministic generators you can prove correct; a game being fun you can't.
- Don't use an LLM for anything code can compute. (§3.) Counting four butterflies is not an AI problem. This is the whole ballgame — cost, latency, and correctness all improve at once.
- "Correct" means what the kid sees, not what's technically true. The moon is astronomically grey; it renders yellow on a phone. A unicorn emoji "is" whatever the color bank says; it renders white-and-pink and the kid taps pink. Audit your emoji/color banks against how they actually render on the target device, and drop multi-color / platform-variant emojis (🦄🐝🐧🐞🧢☂️🐬 bit us) entirely.
- Watch out for the label spelling the answer. A letter question showing the word "OWL" gives away that it starts with O. Lowercase the displayed word, or don't show it.
- Kill trivial formats. "Which letter is W?" with W right there is not a question. Enumerate the formats a generator can emit and remove the degenerate ones.
- The bug you "fixed" but the kid still sees is probably a cache. After a real fix, the child hit the old question again — it was a stale service-worker/browser cache, not the code. Verify the deployment, then give yourself a cache-buster (our "New Day" button fetches fresh seeds).
- Test on the actual kids, early. Every important bug here was caught by a 5- and 10-year-old using the app, not by us reading code. Ship a rough version to a real child fast.
- Prompt caching may not pay off at this scale. We tried it; the model's minimum cacheable prompt size was larger than our whole static prompt, so it never triggered. Don't assume; measure.
- Track your cost from turn one. We didn't, and reconstructing "what did this cost to build" after the fact from token logs was painful and mostly impossible. If cost matters to you, capture it as you go.
Paste this (with your kids' details filled in) after this document. List however many kids you have — one line each, one or ten:
Build the kids' quiz app described in this brief. I have [N] kid(s):
- [Name], age [N], [grade] — interests: [list]. [reading level / any constraints]
- [Name], age [N], [grade] — interests: [list]. [reading level / any constraints]
- (...one line per kid — add or remove lines to match how many you have)
Use [my stack, or "the reference stack in the brief"]. Start by proposing: (a) the per-kid
PlayerConfigfor each child, (b) which question types are deterministic vs. AI-backed for each, and (c) the question JSON contract. Do not write code until I approve those three. Then build the deterministic generators first (with tests asserting the true answer is always a choice), then the engine, then the AI proxy, then the reward layer. I'll test on my kids between steps.
Built for real, running in production for a household of two kids — a 10-year-old son and a 5-year-old daughter — but the design is the same for one kid or a whole classroom. "Son" and "daughter" and their ages/grades are just examples; replace them with your own kids' real profiles.