The user wants a lightweight webapp their kids can install on Android to sharpen
mental math skills across +, −, ×, ÷. Gameplay: timed 60-second rounds
where balloons rise from the bottom of the screen toward spikes at the top, each
carrying a math question. Typing the correct answer saves the balloon and
awards XP. Kids sign in with their own accounts so progress persists.
The project directory /Users/homefolder/Documents/Github/MathWiz is currently
empty — this is a greenfield build. The user will host the deployed app
themselves.
A fun, fast-feeling mental-math drill app that runs full-screen on an Android phone after "Add to Home Screen", with per-kid accounts and persistent stats.
Functional
- Email + password sign-up / sign-in (one account per kid).
- Lobby screen lets the kid pick:
- Difficulty: Easy / Medium / Hard (single-select).
- Operations to include:
+,−,×,÷(multi-select, at least one). - Balloon speed: Chill / Normal / Fast (single-select).
- "Allow negative answers" toggle (only applies when
−is enabled; default off).
- 60-second round. HUD shows: timer, round XP so far, current lifetime streak.
- Balloons spawn at randomised x-positions along the bottom and rise at constant (per-balloon) speed toward spikes at the top. Concurrent balloon count depends on difficulty.
- Each balloon shows a question (e.g.,
7 × 8). Answer ranges:- Easy:
+ −operands 0–10;× ÷answers ≤ 50. - Medium:
+ −operands 0–20;× ÷answers ≤ 100. - Hard:
+ −operands 0–50;× ÷answers ≤ 225. ÷only generates whole-number results.−avoids negative answers unless the toggle is on.
- Easy:
- Telephone-style dialpad input at the bottom of the game area:
1 2 3 4 5 6 7 8 9 − 0 ⌫ - Auto-match: as the kid types, the current input is compared against every visible balloon's answer. First exact match pops the balloon, clears the input, awards XP, and increments the lifetime streak. No Enter needed.
- Streak reset rule: the lifetime correct-answer streak resets to 0 only when a balloon reaches the spikes. Typing wrong digits does not break the streak. When a balloon pops from spikes, the new question on screen gets a little "spike" animation and the HUD streak resets.
- Round end: show summary (round XP, balloons saved, balloons popped, new high score?). Persist stats to Supabase.
- Stats screen shows: high score per round, total lifetime XP, current lifetime correct streak.
Non-functional
- Installable as a PWA (manifest + service worker). Launches full-screen in portrait.
- Works offline for gameplay after first load (stats writes queue until online — nice-to-have, not required for v1).
- Targets a single portrait phone viewport (~360×780). No tablet/desktop layout work in v1.
- Touch-first UI. Buttons sized for kid fingers (≥48px).
In
- React + Vite + TypeScript SPA.
- Supabase auth + Postgres for accounts and stats.
- PWA via
vite-plugin-pwa. - Lobby, game, round-summary, stats, sign-in, sign-up screens.
- Balloon physics via
requestAnimationFrame. - Question generator covering the four operations and the three difficulties.
- SQL migration for the stats table with row-level security.
Out
- Sound effects and music (leave hooks so they can be added later).
- Leaderboards / multiplayer / friends.
- Analytics.
- A real Android APK (Capacitor wrapper). PWA "Add to Home Screen" only.
- Password reset UI (Supabase provides a link flow we can wire later).
- Desktop/tablet-optimised layout.
- A kid can create an account on their Android phone, install the app to the home screen, open it full-screen, play a 60s round, and see their stats update on the stats screen after the round ends.
- High score updates only when a round's XP beats the previous best.
- Total XP accumulates across rounds.
- Lifetime streak grows across rounds and resets only when a balloon pops.
- All four operations generate questions with the specified ranges, and
÷never shows a non-whole-number answer.
Frontend: React 18 + Vite + TypeScript, single-page app. React state for
game loop; no heavyweight game engine needed — the physics is just
"balloon.y -= speed * dt". CSS absolute positioning for balloons over a
position: relative play area.
PWA: vite-plugin-pwa generates the manifest and service worker. Icons
at 192×192 and 512×512, display: "fullscreen", orientation: "portrait".
Backend: Supabase (managed Postgres + auth + row-level security). No
custom server. Client talks to Supabase directly using the anon key. One
public.stats table with a row per user, populated by an auth.users
insert trigger so every new sign-up automatically gets a stats row.
XP formula (per saved balloon):
balloonXP = 10 × difficultyMult × speedMult
difficultyMult: easy 1.0 | medium 1.5 | hard 2.0
speedMult: chill 1.0 | normal 1.5 | fast 2.0
Min 10 XP (easy + chill), max 40 XP (hard + fast). Round XP = sum of saved balloons' XP. "High score" = best single-round XP.
Balloon pacing
- Rise duration (spawn → spikes): chill 15s, normal 10s, fast 6s.
- Max concurrent balloons: easy 3, medium 4, hard 5.
- Spawn interval:
riseDuration / maxConcurrent, so the screen stays roughly full without crowding.
npm create vite@latest . -- --template react-ts- Install deps:
@supabase/supabase-js,vite-plugin-pwa. - Add
.env.examplewithVITE_SUPABASE_URLandVITE_SUPABASE_ANON_KEY. - Configure
vite.config.tswithVitePWA({ registerType: 'autoUpdate', manifest: { name: 'MathWiz', short_name: 'MathWiz', display: 'fullscreen', orientation: 'portrait', theme_color: '#1e3a8a', background_color: '#0b1220', icons: [...] } }). - Place placeholder PWA icons under
public/icons/(192.png, 512.png).
- Create
supabase/migrations/0001_init.sql:create table public.stats ( user_id uuid primary key references auth.users(id) on delete cascade, display_name text, high_score int not null default 0, total_xp int not null default 0, correct_streak int not null default 0, updated_at timestamptz not null default now() ); alter table public.stats enable row level security; create policy "stats_own_row" on public.stats for all using (auth.uid() = user_id) with check (auth.uid() = user_id); create function public.handle_new_user() returns trigger language plpgsql security definer as $$ begin insert into public.stats (user_id, display_name) values (new.id, coalesce(new.raw_user_meta_data->>'display_name', split_part(new.email, '@', 1))); return new; end; $$; create trigger on_auth_user_created after insert on auth.users for each row execute function public.handle_new_user();
src/lib/supabase.ts: create and export the client from env vars.
src/auth/AuthContext.tsx: provider that subscribes tosupabase.auth.onAuthStateChangeand exposes{ session, user, signIn, signUp, signOut }.src/auth/SignInPage.tsx+src/auth/SignUpPage.tsx: simple email + password + display name forms. Sign-up passesdisplay_nameintooptions.dataso the trigger picks it up.src/App.tsx: route on session presence — unauthenticated → SignIn; authenticated → Lobby.
src/game/types.ts:export type Op = '+' | '-' | '×' | '÷'; export type Difficulty = 'easy' | 'medium' | 'hard'; export type Speed = 'chill' | 'normal' | 'fast'; export interface Question { text: string; answer: number; } export interface Balloon { id: number; x: number; y: number; // y in [0,1], 0 = bottom, 1 = spikes speed: number; // units of y per second question: Question; color: string; poppedByUser?: boolean; }
src/game/questions.ts:genQuestion(difficulty, ops, allowNegative): Question.+: picka, buniformly in[0, addMax].−: picka, bin[0, addMax]; if!allowNegative, swap soa ≥ b.×: pickain[0, mulOperandCap], thenbin[0, floor(answerCap / max(a,1))]— guaranteesa*b ≤ answerCap.÷: pickbin[1, mulOperandCap], pickanswerin[0, floor(answerCap / b)], seta = b * answer→a ÷ b = answer.- Render
−as−(U+2212) and×as×(U+00D7) for readability. - Constants:
const CFG = { easy: { addMax: 10, mulOperandCap: 12, mulAnswerCap: 50 }, medium: { addMax: 20, mulOperandCap: 15, mulAnswerCap: 100 }, hard: { addMax: 50, mulOperandCap: 20, mulAnswerCap: 225 }, };
src/game/useGameLoop.ts: custom hook drivingrequestAnimationFrame.- State:
balloons[],timeRemaining,roundXp,streak,popped. - Each frame: compute
dtseconds, advance every balloon'sy += speed*dt. If any balloon crossesy >= 1, remove it, setstreak = 0, play spike animation, setpoppedByUser=false. - Spawn loop: every
spawnIntervalseconds (as long asballoons.length < maxConcurrent), push a new balloon with a randomx ∈ [0.1, 0.9]and a fresh question. - Timer: countdown from 60; when it hits 0, mark round finished and return the summary.
- State:
- Constants file
src/game/config.ts:export const RISE_DURATION: Record<Speed, number> = { chill: 15, normal: 10, fast: 6 }; export const MAX_CONCURRENT: Record<Difficulty, number> = { easy: 3, medium: 4, hard: 5 }; export const DIFFICULTY_MULT: Record<Difficulty, number> = { easy: 1.0, medium: 1.5, hard: 2.0 }; export const SPEED_MULT: Record<Speed, number> = { chill: 1.0, normal: 1.5, fast: 2.0 }; export const BASE_BALLOON_XP = 10; export const ROUND_SECONDS = 60;
src/lobby/LobbyPage.tsx: difficulty buttons, ops multi-select chips, speed buttons, "Allow negative answers" checkbox (greyed out if−not selected), large Start button. Show the kid's name and current lifetime stats at the top.src/game/GamePage.tsx: full-viewport play area.- Top HUD bar: timer (countdown), round XP, lifetime streak.
- Play area (flex: 1): balloons rendered via absolute positioning. Each balloon is a rounded div with the question text centered. Background gradient sky → ceiling with a spike texture at the top.
- Bottom: current input display, then
<Dialpad />.
src/game/Balloon.tsx: presentational; takes{ x, y, color, text, popped }and usestransform: translate(...)fromx, y. CSS transition on pop for a little scale+fade animation.src/game/Dialpad.tsx: 4×3 grid of buttons:1 2 3 / 4 5 6 / 7 8 9 / − 0 ⌫.- Each digit button: append to input string.
−: if input is empty, set to"-"; otherwise ignore.⌫: remove last character.- Parent
GamePagewatches input; on every change, parses it and checks against visible balloons' answers. On match, pops the balloon and clears input.
src/game/RoundSummary.tsx: modal-ish screen shown after round end with round XP, balloons saved/popped, and "New high score!" badge if applicable. Buttons: "Play again" (back to lobby) and "See stats".src/stats/StatsPage.tsx: displays the three persistent numbers.src/stats/useStats.ts: fetchespublic.statsfor the signed-in user and exposes apersistRoundResult({ roundXp, poppedThisRound })function that computes the new values locally andupserts.
On round end in GamePage:
const newTotal = stats.total_xp + roundXp;
const newHigh = Math.max(stats.high_score, roundXp);
// streak was mutated live during the round and is already correct
await supabase.from('stats').upsert({
user_id: user.id,
high_score: newHigh,
total_xp: newTotal,
correct_streak: localStreak,
updated_at: new Date().toISOString(),
});One write per round end. Streak is kept in memory during gameplay and only flushed at round end — simpler and avoids chatty network calls.
- Tailwind would be overkill; use plain CSS with a single
src/styles/index.css. - Dark sky background, bright balloon colors (red/yellow/blue/green/purple), big readable fonts (≥24px for questions, ≥28px for dialpad keys).
- Spike row at top: SVG triangle pattern.
- Full viewport via
html, body, #root { height: 100%; margin: 0; }and#root { display: flex; flex-direction: column; }.
- Verify the manifest works: Chrome DevTools → Application → Manifest shows no errors, "Installable" checkmark.
- Service worker pre-caches the app shell (default vite-plugin-pwa behavior).
- Works on any static host. Recommend Netlify or Vercel:
- Connect the GitHub repo.
- Build command:
npm run build, publish dir:dist. - Env vars:
VITE_SUPABASE_URL,VITE_SUPABASE_ANON_KEY.
- Document this in
README.md.
MathWiz/
├── package.json
├── vite.config.ts
├── tsconfig.json
├── index.html
├── .env.example
├── README.md
├── public/
│ └── icons/ (placeholder 192.png, 512.png)
├── supabase/
│ └── migrations/
│ └── 0001_init.sql (stats table + RLS + trigger)
└── src/
├── main.tsx
├── App.tsx (routing: auth vs. lobby)
├── lib/
│ └── supabase.ts (client init)
├── auth/
│ ├── AuthContext.tsx
│ ├── SignInPage.tsx
│ └── SignUpPage.tsx
├── lobby/
│ └── LobbyPage.tsx (difficulty, ops, speed, neg-toggle, Start)
├── game/
│ ├── types.ts
│ ├── config.ts (RISE_DURATION, MAX_CONCURRENT, mults)
│ ├── questions.ts (genQuestion, CFG ranges)
│ ├── useGameLoop.ts (rAF loop, spawn, tick, streak)
│ ├── GamePage.tsx (HUD + play area + dialpad)
│ ├── Balloon.tsx
│ ├── Dialpad.tsx
│ └── RoundSummary.tsx
├── stats/
│ ├── StatsPage.tsx
│ └── useStats.ts (fetch + persistRoundResult)
└── styles/
└── index.css
Local smoke test
npm install && npm run dev- Open
http://localhost:5173in Chrome with device emulation set to "iPhone 12 Pro" or similar portrait phone. - Sign up with a test email/password and display name.
- Lobby: select Easy, tick only
+, Normal speed. Start. - Play ~10 seconds, type answers via dialpad, confirm balloons pop on match.
- Let one balloon reach the spikes on purpose — confirm streak resets to 0 in the HUD but the round continues.
- Wait for 60s timer to end — confirm round summary shows round XP and (if first round) "New high score".
- Navigate to Stats — confirm total XP and high score match.
- Sign out, sign back in — stats persist.
- Repeat with
−enabled and the "allow negatives" toggle on; confirm questions like3 − 7appear and can be answered with−4. - Repeat with
×and÷on Hard; confirm ÷ problems always have whole-number answers and × answers stay ≤225.
Question generator sanity check
- Add a throwaway
npm run check:questionsscript (or a top-of-file test inquestions.ts) that generates 1000 questions at each difficulty and asserts the answer ranges hold. Remove after verification.
PWA install test
- Chrome DevTools → Application → Manifest → "Installable" is green.
- Deploy to Netlify/Vercel. Open on an actual Android phone, tap "Add to Home Screen" in Chrome's menu, launch from the home screen, confirm it opens full-screen in portrait with the MathWiz icon.
Supabase check
- Open the Supabase dashboard → Table editor →
stats→ confirm the test user's row has updated values after a round. - Confirm RLS: from a SQL editor session as a different user, verify you cannot read other users' rows.
- Sounds / haptics on pop.
- Daily streak / badges.
- Per-operation accuracy breakdowns.
- Parent dashboard across all kids.
- Offline round queueing.