Inspiration
Two problems, one root cause.
The first: I wanted to fix my squat depth, and the only honest feedback available was a mirror or a coach I couldn't afford. Fitness apps count reps by counting phone shakes. They'll happily tell you that you did 20 perfect squats when you did 20 half-squats with your knees caving in — which is worse than no feedback, because you leave believing you trained well.
The second: my city has courts, pitches and parks that are empty most evenings, and people at home who'd play if they knew a game was happening. The problem was never facilities. It was that nobody knew who else wanted to play.
Both are the same failure — good information exists, but it never reaches the person who needs it. Huddle is one app for both: an AI coach that tells you the truth about your form, and a map that tells you where the game is.
What it does
The form coach. Point your webcam at yourself and pick from 20 movements. MediaPipe's pose model tracks 33 body landmarks in real time, a state machine counts reps from actual joint angles, and a form score starts at 100 and drops while you break a rule. When your knees cave in, it says "push your knees out" — out loud, while you're in the rep, not in a summary afterwards. Every session ends with a score, a rep count, and which cue cost you the most.
Before your first attempt at any movement there's a three-step tutorial: an animated figure showing what the movement looks like, where to put your camera, and every single thing that will be scored — quoting the exact words the coach will speak, so nothing mid-session is a surprise.
The anatomy map. An interactive body diagram, front and back. Hover an exercise and its main movers fill dark orange, its assisting muscles light orange. Tap a muscle and the library narrows to what trains it. It works in both directions, because sometimes you know the exercise and sometimes you just know your shoulders hurt.
The game finder. A 3D map of pickup games near you across 14 sports. Tap a pin and the details open beside the map — when, where, how far, who's going, what it's like. Join, or mark yourself as maybe, or leave a comment. Host your own and drop the pin exactly where the game is.
The connective tissue. Eight multi-day training programs, badges, missions, and a streak — plus a warm-up check-in that scores your readiness before a game and shares it with the group.
How we built it
A React 19 + TypeScript PWA on Vite, styled with Tailwind v4 design tokens.
Pose detection runs entirely in the browser. MediaPipe Tasks Vision with the WASM runtime and GPU delegate, model vendored locally. No video ever leaves the device — not as a privacy feature bolted on afterwards, but as the architecture. There is no server to send it to.
Rep counting is a hysteresis state machine. Two separate angle thresholds — one to enter the bottom, a different one to leave it — plus a frame debounce. A single threshold makes the counter flicker wildly when you hover near it. Real bodies shake; the counter shouldn't.
Holds are a different engine entirely. A plank has no repetitions, and pretending otherwise gives you a counter that either sits at zero or ticks on camera noise. HoldEngine scores time-in-position multiplied by the share of that time your alignment held.
Data sits behind a swappable repository interface — a mock implementation backed by localStorage, and a Firestore one — so every screen was built and tested before the backend existed, and switching is six environment variables.
Mapbox GL for the 3D map, DOM markers, and the coordinate picker when hosting.
The anatomy figure is code, not artwork, because a picture can't recolour per muscle or accept a click on one region. Hand-authored bezier paths, authored for one side and mirrored.
75 unit tests and a 12-step Playwright suite that drives the real app in a real browser.
Challenges we ran into
The start button was dead. For every exercise. begin() guarded on the video element's ref — but that element only exists on the live screen, so on the setup screen the ref was always null and every click returned instantly and in complete silence. The centrepiece of the app couldn't be started. The smoke test passed the whole time, because it asserted the setup screen rendered and stopped there.
Form cues fired at the wrong time. The rep engine ran its checks before resolving the state transition, so every check read a stale phase. "Go deeper" and "control the release" could never fire — they were watching for a phase the engine hadn't entered yet.
Map pins slid around on every zoom. Mapbox writes transform: translate(…) onto each marker every frame to position it. I'd put a CSS transition on transform for the hover lift — so every reposition animated, and pins eased toward their new coordinates instead of snapping. Fixed by making the marker root a bare positioning shell and moving all visuals to an inner element. Verified by measuring a pin's screen box across a zoom round-trip: 0.0px.
Then two more in the same area: mapbox-gl.css sets .mapboxgl-map { position: relative } and loads after Tailwind, so it won the cascade and my absolute inset-0 container collapsed to the height of the attribution bar. And markers were positioned only at creation, so when games moved the pins stayed behind and Mapbox parked them off-screen at 2²⁵.
A 30-second breather counted as plank time. The hold engine credited the entire gap between two in-position frames — so pausing and resuming added the whole rest to your score.
The map opened in the Gulf of Guinea. VITE_DEMO_CITY_LAT= is an empty string, not undefined, so ?? passed it straight through and Number('') is 0. Coordinates 0°, 0°.
Every distance read 7,778 km, because the demo data sat in New Jersey and I don't.
The covers were 20 MB. Ten ~2 MB PNGs painted at 438×246, and a 16:9 photo squeezed into a 3.9:1 strip so object-cover cropped every athlete's head off.
Accomplishments that we're proud of
It's honest. 20 exercises, not 35 — every one is genuinely measurable from a single camera with the engines I built. I cut the ones I couldn't score truthfully rather than shipping a number that means nothing. The one thing this app must not do is lie to you about your training.
Nothing leaves your device. Full pose estimation in the browser. Your webcam feed is never uploaded, never stored, never seen by me.
The bugs got measured, not eyeballed. "The pins look stable now" isn't a fix. Recording a marker's screen box before and after a zoom round-trip and getting 0.0px drift is.
The tests learned to catch the right class of bug. After the dead start button, the smoke suite stopped asserting that screens render and started asserting that features work — it clicks through to a live camera frame, and it compares the demonstration figure's path data across frames to prove it's actually animating.
Every screen works before the backend exists. The repository interface meant a Firebase outage was never a demo risk.
The anatomy map goes both directions. Exercise → muscles, and muscle → exercises, from one data model with tests that guarantee no clickable region is ever a dead end.
What we learned
A test that asserts the screen renders is not a test that the feature works. My smoke suite passed green for as long as the app's core flow was completely broken. It checked that the setup screen appeared. It never pressed the button.
Don't fight a library for the same CSS property. Both the marker drift and the collapsed canvas were the same mistake: assuming my styles owned something Mapbox also drives. Give the library its element and put your own on a different one.
?? is not a null check for environment variables. An empty string is not nullish, and it will happily become 0.
Draw the diagram for legibility, not for accuracy. I built the squat demonstration facing the camera, to match where the coach asks you to stand. It read as a stick figure standing perfectly still — because a squat seen from the front barely moves, the whole hinge travels along the axis pointing at the lens. Turning it side-on made it instantly readable.
Optimise the thing you're actually shipping. 20 MB of photography to fill about a megapixel of screen. Nobody sees a 2 MB PNG at 438 pixels wide — they just wait for it.
What's next for Huddle
Live backend. Firestore and multi-device auth are written and waiting on two console switches. Rules and offline persistence are already in the repo.
Auto-completing program days. Right now you mark a day done. Finishing the actual session should do it for you.
Rep tempo and range analytics. The engine already records the descent frames and minimum angle of every rep. That data is enough to show whether your third set is quietly getting sloppier — which is the thing nobody notices about themselves.
More movements. Every addition has to clear the same bar: measurable from one camera, honestly. That's the constraint, and it stays.
Group sessions. Two people, one camera, both scored — the warm-up check-in is already the seed of it.
Recurring games and venue profiles. Most pickup games are the same people, the same court, every week. Hosting that repeatedly shouldn't mean creating it from scratch every time.
Built With
- computer-vision
- firebase
- firebase-auth
- firestore
- gps
- javascript
- machine-learning
- mapbox
- mapbox-gl-js
- mediapipe
- playwright
- pose-estimation
- pwa
- react
- react-router
- svg
- tailwindcss
- tensorflow
- typescript
- vite
- vitest
- web-speech-api
- webassembly
- webgl
- webrtc
Log in or sign up for Devpost to join the conversation.