A webcam finger-walking running game. "Walk" your index and middle fingers in front of the camera like two little legs β every time the two fingertips cross each other counts as one step, and every step moves your runner one fixed stride. Rack up the most steps (or reach the finish line) before the timer runs out, then put your name on the leaderboard.
Inspired by viral gesture-counting hand challenges. All hand tracking runs in your browser β video frames never leave your device.
Three packages in one repo, with a hard frontend β backend split. They share only a tiny TypeScript types package and talk only over a documented API (REST for session lifecycle, WebSocket for live gameplay).
finger-sprint/
βββ shared/ # @finger-sprint/shared β TS types for the API contract (no runtime code)
βββ backend/ # Node + Express + ws β sessions, authoritative game loop, scoring, SQLite
βββ frontend/ # React + Vite β webcam, MediaPipe hand tracking, Canvas rendering
βββ README.md
| Concern | Frontend (client) | Backend (server) |
|---|---|---|
Webcam capture (getUserMedia) |
β | β |
| Hand tracking (MediaPipe, client-side) | β | β |
| Step counting (fingertip crossings) | β detects & counts | β |
| Steps β distance & scoring | β | β authoritative |
| Scoring & validation | β | β single source of truth |
| Rendering (character, meters, timer) | β visualizes server state | β |
| Leaderboard persistence | β | β SQLite |
The frontend is deliberately "dumb" about scoring: it reports its flat step count and renders whatever state the server says is true. Scores are computed and rate-capped server-side so they can't be trivially faked from the client.
Webcam ββΆ MediaPipe Hands ββΆ 21 landmarks/frame
β
βΌ
fingerLegs.ts + stepCounter.ts (raw index/middle tip
positions in the hand frame; +1 step per genuine crossing)
β every ~100ms (NOT every frame)
βΌ
WebSocket { type:"movement", sessionId, steps, timestamp }
β (steps = flat cumulative total)
βΌ
βββββββββββββββββββββββββ BACKEND ββββββββββββββββββββββββββ
β game loop @100ms: accepted steps (rate-capped) β distance β
β += steps Β· stride β score β win/lose checks β
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β every tick
βΌ
WebSocket { type:"state", position, speed, distance,
steps, score, timeRemaining, finished }
β
βΌ
Canvas renderer (runner, parallax, finish line, HUD)
Session lifecycle uses REST; the live loop uses one WebSocket per session.
Requires Node 20+ (built and tested on Node 24). SQLite uses Node's built-in
node:sqlite, so there's no native build step β a plain install just works.
# from the repo root β installs all three workspaces
npm installnpm run dev- Backend β http://localhost:4000
- Frontend β http://localhost:5173 β open this
Vite proxies /api and /ws to the backend, so the browser uses same-origin URLs.
npm run dev:backend # just the API + WebSocket server (port 4000)
npm run dev:frontend # just the Vite dev server (port 5173)Each workspace is self-contained and can be developed on its own:
npm run dev -w @finger-sprint/backend
npm run dev -w @finger-sprint/frontend
npm run build -w @finger-sprint/frontend # production bundle
npm run typecheck # typecheck both sidesFirst run: the browser will ask for camera permission, and MediaPipe downloads its model + WASM from a CDN the first time (a few MB). Use the app over
http://localhostorhttps://βgetUserMediarequires a secure context.
Backend (all optional β see backend/.env.example):
| Var | Default | Meaning |
|---|---|---|
PORT |
4000 |
HTTP + WebSocket port |
DB_PATH |
./data/leaderboard.db |
SQLite file (auto-created) |
Game tuning (round length, stride distance, scoring) lives in
backend/src/config.ts. Step-detection sensitivity
(the crossing margin) lives in
frontend/src/game/stepCounter.ts
β both are isolated and commented for easy tweaking.
Base URL: http://localhost:4000. All bodies are JSON. Types are defined once in
shared/src/index.ts and imported by both sides.
Create a new session.
Finalize a session and return its score + provisional rank. Idempotent.
// 200 OK
{
"sessionId": "uuid",
"score": 9588,
"distance": 4032,
"finished": true,
"rank": 1, // 1-based rank vs the persisted leaderboard
"durationMs": 90000
}
// 404 if the session doesn't existTop N scores (default 10, max 100), highest first.
// 200 OK
{
"entries": [
{ "id": 1, "name": "Ada", "score": 14820, "distance": 7560,
"createdAt": "2026-06-13T18:19:29.606Z", "rank": 1 }
]
}Attach a name to a finished session's score (server reads the score from the session β the client can't supply it).
// request
{ "sessionId": "uuid", "name": "Ada" }
// 201 Created
{ "entry": { "id": 1, "name": "Ada", "score": 14820, "distance": 7560,
"createdAt": "...", "rank": 1 } }
// 400 if name is empty / missing
// 404 if the session doesn't exist
// 409 if the session isn't finished yetOpen one socket per session (pass the id as a query param). The server starts the
authoritative clock on connect and streams state until the round is finished.
client β server (send on a fixed ~100ms tick, not every frame). steps
is the flat, cumulative total of fingertip crossings this round β a count, not
a rate β so lost or reordered messages self-heal via the delta:
{ "type": "movement", "sessionId": "uuid", "steps": 42, "timestamp": 1700000000000 }server β client (one per game tick):
{
"type": "state",
"position": 0.42, // 0..1 progress along the track
"speed": 210, // display pace derived from stepping (units/s)
"distance": 7560, // distance covered (steps Γ stride)
"steps": 108, // total steps the server has accepted
"score": 14820, // banked, combo-weighted score
"multiplier": 2.3, // live sustained-effort combo (>= 1)
"timeRemaining": 52400, // ms left
"finished": false
}The server may also send { "type": "error", "message": "..." } (e.g. unknown
session). Malformed frames are ignored.
A step has exactly one definition: the pointer/index fingertip (MediaPipe
landmark 8) and the middle fingertip (landmark 12) physically pass each other.
Per video frame, fingerLegs.ts +
stepCounter.ts:
- Project both fingertips onto the hand axis (wrist β middle knuckle), normalized by hand size β so moving, rotating, or zooming the whole hand changes nothing; only real finger motion does.
- Compare the two raw tip positions: whichever reaches further is "in
front". A tip only counts as clearly in front once it's past the other by a
margin (
crossMargin) β the clear boundary between "pointer leads" and "middle leads"; jitter inside that dead band is ignored. - Count one step each time the leading tip flips from one finger to the other β i.e. the tips genuinely crossed. Wiggling a single finger without its tip passing the other can never flip the leader, so it never counts.
The result is a flat cumulative count β no velocity, no smoothing, no decay. One crossing = one step, always.
All scoring is server-side (backend/src/game/engine.ts,
tuned in backend/src/config.ts). A 90-second round works
like this:
- Flat steps β distance. Every accepted step advances the runner exactly
distancePerStepunits (one stride), and each tick you bank the ground covered Γ a points rate. N steps is always N strides β there is no speed curve to game. A server-side rate cap (maxStepsPerSecond, far above real finger speed) discards impossible bursts instead of banking them. - Sustained-effort combo (Γ1 β Γ3). A multiplier builds while your
stepping pace stays above a threshold and decays ~2Γ faster when you drop
below it. Banked points are weighted by the live multiplier, so consistent
stepping scores far more than one-off bursts. The current combo streams in the
statemessage (multiplier) and shows live in the HUD. - Finish bonus. Reaching the finish line (
trackLength, ~18k units β an endurance goal that takes most of the round) adds a bonus per second left on the clock. It's kept modest so it rewards a strong finish without dominating.
This makes the leaderboard skill-expressive: distance, consistency, and a clean
finish all matter. Tune any of it via the game block in config.ts.
The client handles, with dedicated UI: webcam unsupported, permission denied, no camera found, model load failure (retryable), and no hand detected (the "Start sprint" button stays disabled until a hand appears, and the runner decays to a stop mid-round if tracking is lost).
- Frontend: React + TypeScript + Vite,
@mediapipe/tasks-vision(Hand Landmarker), Canvas 2D. - Backend: Node.js + TypeScript + Express (REST) +
ws(WebSocket), run withtsx. - DB: SQLite via built-in
node:sqlite, behind aLeaderboardRepointerface so it can be swapped for Postgres/etc. without touching game code. - Shared: a types-only TypeScript package β the entire cross-boundary surface.
Note:
npm auditreports advisories in dev-only tooling (Vite/esbuild, concurrently). These do not ship in the running app. Bumping Vite to v8 clears the esbuild ones if desired.