Skip to content

Latest commit

 

History

History
384 lines (343 loc) · 16.3 KB

File metadata and controls

384 lines (343 loc) · 16.3 KB

MathWiz — Kids' Mental Math Balloon Game

Context

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.

PRD

Goal

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.

Requirements

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.
  • 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).

Scope

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.

Success Criteria

  • 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.

Chosen Approach

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.

Implementation Plan

1. Project scaffold

  • npm create vite@latest . -- --template react-ts
  • Install deps: @supabase/supabase-js, vite-plugin-pwa.
  • Add .env.example with VITE_SUPABASE_URL and VITE_SUPABASE_ANON_KEY.
  • Configure vite.config.ts with VitePWA({ 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).

2. Supabase setup

  • 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.

3. Auth

  • src/auth/AuthContext.tsx: provider that subscribes to supabase.auth.onAuthStateChange and exposes { session, user, signIn, signUp, signOut }.
  • src/auth/SignInPage.tsx + src/auth/SignUpPage.tsx: simple email + password + display name forms. Sign-up passes display_name into options.data so the trigger picks it up.
  • src/App.tsx: route on session presence — unauthenticated → SignIn; authenticated → Lobby.

4. Game model & question generator

  • 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.
    • +: pick a, b uniformly in [0, addMax].
    • : pick a, b in [0, addMax]; if !allowNegative, swap so a ≥ b.
    • ×: pick a in [0, mulOperandCap], then b in [0, floor(answerCap / max(a,1))] — guarantees a*b ≤ answerCap.
    • ÷: pick b in [1, mulOperandCap], pick answer in [0, floor(answerCap / b)], set a = b * answera ÷ 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 },
      };

5. Game loop

  • src/game/useGameLoop.ts: custom hook driving requestAnimationFrame.
    • State: balloons[], timeRemaining, roundXp, streak, popped.
    • Each frame: compute dt seconds, advance every balloon's y += speed*dt. If any balloon crosses y >= 1, remove it, set streak = 0, play spike animation, set poppedByUser=false.
    • Spawn loop: every spawnInterval seconds (as long as balloons.length < maxConcurrent), push a new balloon with a random x ∈ [0.1, 0.9] and a fresh question.
    • Timer: countdown from 60; when it hits 0, mark round finished and return the summary.
  • 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;

6. UI components

  • 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 uses transform: translate(...) from x, 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 GamePage watches 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: fetches public.stats for the signed-in user and exposes a persistRoundResult({ roundXp, poppedThisRound }) function that computes the new values locally and upserts.

7. Round persistence

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.

8. Styling

  • 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; }.

9. PWA polish

  • 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).

10. Hosting

  • 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.

Critical Files (to be created)

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

Verification

Local smoke test

  1. npm install && npm run dev
  2. Open http://localhost:5173 in Chrome with device emulation set to "iPhone 12 Pro" or similar portrait phone.
  3. Sign up with a test email/password and display name.
  4. Lobby: select Easy, tick only +, Normal speed. Start.
  5. Play ~10 seconds, type answers via dialpad, confirm balloons pop on match.
  6. Let one balloon reach the spikes on purpose — confirm streak resets to 0 in the HUD but the round continues.
  7. Wait for 60s timer to end — confirm round summary shows round XP and (if first round) "New high score".
  8. Navigate to Stats — confirm total XP and high score match.
  9. Sign out, sign back in — stats persist.
  10. Repeat with enabled and the "allow negatives" toggle on; confirm questions like 3 − 7 appear and can be answered with −4.
  11. 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:questions script (or a top-of-file test in questions.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.

Follow-ups not in scope

  • Sounds / haptics on pop.
  • Daily streak / badges.
  • Per-operation accuracy breakdowns.
  • Parent dashboard across all kids.
  • Offline round queueing.