← Back to Projects

Little Sprouts

Active

A static browser app of early-learning games. One learner record in localStorage drives every round. Questions come from a CSV. Speech and pose tracking stay on the device.

JavaScriptCanvasWeb WorkersIndexedDBMediaPipeThree.jsnginx
Little Sprouts

Why it exists

Each game was about to grow its own profile, question list, and voice. Little Sprouts keeps one learner record and lets every round read it.

In the build

Question generation in logic.mjs, the sidecar postMessage bridge, on-device Kokoro, and MediaPipe reach.

Introduction

Little Sprouts is a no-build static site. index.html loads app.js as a module. Each game is its own module with a mount* / stop* pair. Round rules that do not touch the DOM live in logic.mjs and run under node --test.

Live: https://grow.360web.cloud
Source: mitchelldawkinsjr/little-sprouts

The product story is in Little Sprouts: Play That Teaches.

Status: Active — live at grow.360web.cloud. The same files run locally with python3 -m http.server. A Dockerfile serves them from nginx and builds the Backroad rally sidecar, whose stage files are not in git.

Shape safari asking the learner to find the circle

Problem & Solution

The Problem

  • Per-game state would split stars, levels, and sight words across siblings and across toys
  • Canvas games cannot share a DOM form with a printable floor-card round
  • A bundled SPA is the wrong shape for a folder of games, some of which are other people’s pages
  • Speech and pose models freeze the main thread and must not upload a child’s video or voice

The Solution

  1. One record in localStorage key sprouts-v1, up to 8 learners
  2. logic.mjs builds the question, the choices, and the score update
  3. app.js picks a renderer from the play style: tap, move, hunt, or reach
  4. Sidecar iframes for games that already have their own document
  5. A worker for Kokoro, and MediaPipe Pose only after a grown-up turns Reach on

Architecture Overview

LayerFilesRole
Shellindex.html, style.css, app.jsShelf, parent gate, round loop, save
Ruleslogic.mjsQuestions, boards, scoring, CSV parse
Page gamesarcade.js, lunar.js, hook.js, chase.js, hop.js, …Canvas or DOM rounds inside the shell
Sidecarssidecar.js, sidecar-guest.js, sidecars/*Own document, postMessage bridge
Speechspeech.js, speech-worker.jsKokoro in a worker, speechSynthesis fallback
Reachcamera.jsPose landmarker, dwell-to-tap
Questionssheet.csvLocal rows, overlaid by a published Google Sheet
Testslogic.test.mjs, camera.test.mjs, sidecars/bike/dirt-bike.test.mjsnode:test, no browser
HostDockerfile, nginx.confnginx static files, COOP / COEP for WASM threads

A round does not go through a server.

Parent corner → sprouts-v1
sheet.csv + Google Sheet CSV → parseSheet → mergeSheets
        ↓
logic.mjs builds the question for this learner, level, and style
        ↓
app.js mounts a module  —or—  postMessage into a sidecar iframe
        ↓
applyRound writes stars, first tries, and skills[mode][key]
Parent corner for the learner, garden buddy, and name

Learner record

defaults() seeds three profiles. Names, stages, and starting levels are data, not accounts: Malik preschool level 1, Micah kindergarten level 2, Mitch 4th grade level 3. A grown-up can rename them.

{
  active: 0,
  sound: true,
  voice: 'af_heart',
  camera: false,
  profiles: [{
    name, stage,            // preschool | kindergarten | grade4
    level, rounds,          // rounds is only 5, 10, or 15
    style,                  // tap | move | hunt | reach
    sprite, words,          // fox, bear, bunny, frog, owl, chick
    stars, earned,
    sessions, correct, total,
    levels: { count, path },
    skills: { [gameId]: { [progressKey]: { total, correct, sessions } } }
  }],
  sheet: { at, rows, note, games, error }
}

progressKey(style, level) stores tap practice under the level number and every other style under style + level, so a Move round does not overwrite Tap. applyRound adds the round length to stars, adds first-try hits to correct, and bumps skills[mode][key]. resetProgress clears stars and skills and leaves the name, levels, and words.

Gas race standings are a second key, sprouts-race-board, because that board is shared across learners.

Parent corner is not a password. gateQuestion() draws a teen addition problem. gateOk checks the typed sum, then the form can edit the active profile. Words must pass validWord: letters, at most one apostrophe, up to 12 characters, at least three distinct words.

Question sheet

loadSheet() fetches ./sheet.csv, then a published Google Sheet CSV. parseSheet wants columns learner, game, item, and optional extra. A hide / off cell drops the row. Game values collapse to words, count, hunt, or facts. A blank learner is *.

mergeSheets lets the Google Sheet replace the games it actually contains. Default facts and band words (preschool, kindergarten, grade4) are added back when the overlay does not already have that item. The merged rows are saved on the profile blob so a failed reload still has the last good sheet. The status line is the only network error the parent sees.

factSetFromRows turns facts rows into multiplication tables (2–12), decades, and double-digit switches. countQuestion, pathQuestion, and grade4Prompt then build the prompt for the stage. 4th grade questions do not show a picture (show: 'none').

Play styles

The style is a field on the profile, not a separate app. app.js rewrites the prompt and the controls.

StyleWho answersWhat changes
TapThe child, on screenChoices are buttons or canvas targets
MoveA grown-up taps ✓Floor cards, no picture on the screen
HuntA grown-up taps ✓The prompt names a real object. What’s missing? falls back to Move
ReachA held handcamera.js turns a wrist into a tap. Off until camera: true

If the saved style is reach and data.camera is false, the shell plays the round as tap and hides the Reach button. When Reach is on, choice buttons get data-zone. Catch the answer and Hook the fish steer from watchHands. Word lanes uses watchBody and a 10 second step into the lane. camera.js is imported the first time a round needs it. If that import fails, the status line says so and the buttons still take a tap. Move and Hunt do not rewrite the arcade canvases. A finished round still ends in applyRound.

Sprout hop, bouncing toward leaves labeled with sight words

Speech

speech.js owns the AudioContext on the main thread. Generation runs in speech-worker.js so a prompt cannot freeze the Next button.

PieceChoice
Runtime[email protected] from jsDelivr
Modelonnx-community/Kokoro-82M-v1.0-ONNX
Builddtype: 'q8', device: 'wasm'
Cache24 clips in the worker Map, 80 in IndexedDB sprouts-voice v3
FallbackIf the clip is not ready in 220ms, speechSynthesis speaks the line

The model returns a view of its own buffer. The worker copies every sample into a new Float32Array before the next line reuses that memory. A clip is rejected unless it has a sane peak, RMS, and zero-crossing rate, so static does not get cached. Default voice is af_heart. The voice id is shared by every learner on the device.

Reach

camera.js loads MediaPipe Tasks Vision 1.1.1 and the float16 pose landmarker (full, then lite). Frames are read in this tab. Nothing is recorded, stored, or uploaded.

pickPose keeps the pose with the widest visible shoulders. pointerNorm blends wrist and fingertip. mapCover mirrors x so a raised right hand lands on the right side of the mirrored video. A hand has to dwell 700ms on a [data-zone] target. 200ms of grace covers a flicker. 320ms away counts as lost. The default profile has camera: false, so a new device never opens the camera until Parent corner allows it.

camera.test.mjs checks the mirror math, pose pick, and shoulder midpoint with fake landmarks. No camera required.

Sidecars

Rally, Sky flight, Dirt ride, Garden walk, and Paint sphere boot in an iframe. findBuiltSidecars() HEADs each src. A missing page is dropped from the shelf. Rally’s stage files are gitignored, so that card stays hidden unless tools/build-sidecar.sh has filled sidecars/rally/.

Messages are { source: 'sprouts-sidecar', type, ... }.

DirectionTypes
Host → guestboot, ask, mark, config
Guest → hostready, show, pick, slip, progress, finish, quit, need

The guest does not read sprouts-v1. sidecar-guest.js exposes SproutsSidecar.ready(), .progress(), .finish(), and .quit(). On rally, ask pauses the drive, paints the question, then resumes. A right answer holds a short turbo. A wrong answer holds the brake. Sky flight, the bike, the walk, and the paint sphere use the same bridge with their own mark handling.

Sky shot, a ship aiming at numbered rocks in a starfield Paint sphere, looking down a track inside a painted shell

What each engine actually uses

Most modules draw their own pictures and call logic.mjs for the board. A few reuse a published mechanic and say so in the file header.

ModuleBorrowed pieceLocal piece
arcade.jsJake Gordon road projection (MIT)Word lanes and Gas race questions
chase.jshellokatili maze movementBerries labeled from this learner
hop.jsFRADAR jump physicsSprout, leaves, and the word on each leaf
toast.jsMegafry arc and livesToast labeled from the profile
sidecars/walkBlocksJS 0.5.32 (William Malone, MIT)Garden signs and the match
sidecars/flightOpenSkyFlight chase camera, pjedvaj Rafale glTFGate questions from sim.mjs
sidecars/paint, flightThree.js (MIT, 2010–2024)Shell halves and the learner’s math
sidecars/bikeNone. LICENSE is Little Sprouts, 2026Canvas bike, dirt-bike.js
hook.js, planet.js, sky.js, match.jsIdea only. No imported game codeDrawing and questions

Kokoro is Apache 2.0. MediaPipe Pose is Google’s model, loaded at runtime. Outfit and DM Sans come from Google Fonts.

Tests and local run

python3 -m http.server 8000
node --test logic.test.mjs camera.test.mjs
node --test sidecars/bike/dirt-bike.test.mjs

logic.test.mjs also imports the flight step (sim.mjs, terrain.mjs) and the sidecar registry, so a board-size or gate change fails without opening a browser. camera.test.mjs imports the pose helpers only.

Open http://localhost:8000. Module URLs carry a ?v= query so a static host does not keep a stale app.js. There is no bundler on the shell.

Deploy

FROM node:24-bookworm AS sidecars
# tools/build-sidecar.sh → /out/sidecars/rally

FROM nginx:alpine
# flight, bike, walk, paint copied as-is
# shell modules copied to /usr/share/nginx/html

nginx.conf turns on gzip and sends Cross-Origin-Opener-Policy: same-origin plus Cross-Origin-Embedder-Policy: credentialless. That is what lets the Kokoro WASM build use more than one thread without breaking font and CDN loads. .mjs, .js, .css, and .csv are Cache-Control: no-cache. .wasm is served as application/wasm.

ItemDetail
URLhttps://grow.360web.cloud
Runtimenginx serving the static folder
AccountsNone. Progress stays in the browser
MediaNothing is uploaded

There is no app server. The phone opens grow.360web.cloud.

Lessons learned

  • Pure round rules belong in logic.mjs so node:test can run them without a DOM.
  • A sidecar must not store the learner. The host posts the question and scores the answer.
  • Speech has to fail open in about 220ms or the next tap sits in silence.

Roadmap

  • Keep sheet.csv the parent-editable source of words and facts
  • Leave tap play working when the pose model or camera never arrives

Who This Is For

  • Builders shipping a static app with no account system
  • People wiring one profile into many canvas games
  • Anyone running speech or pose models in the browser