Head-to-head typing competition for a live event. Built on Next.js, Supabase (Postgres + Realtime), and deployed to Vercel.
Attendee / Player / Staff browsers
│
▼
Vercel (Next.js)
├── serves the React UI
└── /api/* serverless routes
│
▼
Supabase
├── Postgres (durable state)
└── Realtime (change feeds to browsers)
- Browsers subscribe to
races,final,final_runs,queue,eventvia Supabase Realtime. On any change, they refetch/api/stateand re-render. - Vercel serverless API routes use the Supabase service role key to write to Postgres. They are the only writers.
- Race timing is wall-clock-based. The DB stores absolute
starts_atandends_attimestamps; clients compute "remaining seconds" from their local clock. There is no in-memory server state — any serverless invocation can serve any request. - Scoring is deterministic and computed on the server from the final typed string + elapsed ms.
- Finalization is idempotent. When a client observes the deadline has
passed, it calls
POST /api/race/finalize. The server no-ops if the race is already done, so duplicate calls are safe.
- WPM =
(correct_chars / 5) / (elapsed_ms / 60000) - Accuracy % =
correct_chars / typed_len * 100 - Score =
WPM × (acc/100)² × 10— squaring accuracy penalizes sloppy typing. - Untyped tail is not counted as error (just lowers WPM).
- Ties broken by: score → accuracy → correct_chars → earlier finish.
- Error categories:
case_mismatch,duplicate,transposition,other.
- Scoped to the current
event_day. - Players keyed by normalized
(name, company)per day. - Leaderboard score = player's best score that day.
- Top 5 = first 5 by score desc, tiebreak acc desc, earliest achievement.
- Admin locks top 5 → a
finalrow is created with a randomizedorder_json. - Admin clicks "Start next finalist" for each run. Same passage, same duration.
- Final ranking is computed only from final-round scores.
- When the 5th run finishes, event status →
final_done. Normal races blocked. - Optional "CEO bonus round" is a separate
finalrow (isolated scoreboard).
- Go to supabase.com → New project. Pick a region close to your Vercel region. Set a strong database password (save it).
- Open the SQL editor and paste the entire contents of
supabase/schema.sql. Click Run. Safe to re-run; everything is idempotent. - In Project Settings → API, copy:
URL→ this isNEXT_PUBLIC_SUPABASE_URLanonkey → this isNEXT_PUBLIC_SUPABASE_ANON_KEYservice_rolekey → this isSUPABASE_SERVICE_ROLE_KEY(server-only!)
cp .env.example .env.local
# fill in the four env vars (ADMIN_TOKEN can be blank in dev)
npm install
npm run dev
# open http://localhost:3000npm i -g vercel
vercel # creates the Vercel project, follow promptsThen, in the Vercel dashboard → your project → Settings → Environment Variables, set these four:
| Name | Value |
|---|---|
NEXT_PUBLIC_SUPABASE_URL |
your Supabase URL |
NEXT_PUBLIC_SUPABASE_ANON_KEY |
your anon key |
SUPABASE_SERVICE_ROLE_KEY |
your service_role key (server-only) |
ADMIN_TOKEN |
a random string, e.g. openssl rand -hex 24 |
Then:
vercel --prodVercel → Project → Domains → add race.yourdomain.com. Set the DNS CNAME
to cname.vercel-dns.com. HTTPS is automatic.
One-time on each staff laptop:
- Open
https://<your-domain>/admin. - Paste your
ADMIN_TOKENinto the Admin token field → Save. Stored in that browser's localStorage.
Before the event:
- Staff machine:
/admintab. - Player 1 laptop:
/lane/1tab. - Player 2 laptop:
/lane/2tab. - Venue display:
/leaderboard. - Sign-up laptop or QR code pointing to:
/signup.
Running a race:
- In Admin, click
→ P1and→ P2next to two queued players (or type names). - Click ▶︎ Start race. 3-2-1-GO on both lanes.
- Race auto-ends at the deadline. Leaderboard updates.
- Repeat.
Other common actions:
- No-show: click Skip next to the queued player.
- Abort a race: Abort current in Admin.
- Run the final: 🔒 Lock top-5 final → switch display to
/final→ click ▶︎ Start next finalist five times. - Export: 📋 Export CSV copies a CSV to your clipboard.
- Reset for a new day: ⚠︎ Reset event day → confirm.
| Scenario | Behavior |
|---|---|
| Client browser reload | Reconnects, refetches state, rejoins the current race. |
| Race deadline hits | Any client that sees now >= ends_at calls /api/race/finalize. Idempotent, so duplicates are safe. |
| Network hiccup on submit | Client retries on the next render; the 10s polling fallback also triggers. |
Duplicate (name, company) signup |
Reuses existing player id. |
| Same player plays multiple races | Only best score counts on the leaderboard. |
| Admin crashes browser mid-final | Reload /admin, click Start next finalist to continue; final.current_index is persisted. |
| Event-day reset | Bumps event.event_day. Old day's data remains in Postgres for export/audit but UI queries filter by current day. |
- Cold starts on Vercel serverless can add ~100–400ms to the first request after idle. Mitigation: ping the site every few minutes during the event, or upgrade to Vercel Pro for always-warm functions.
- Admin token is a single shared secret, stored in localStorage. For a
multi-staff setup with audit trail, wire up Supabase Auth and replace
ADMIN_TOKENchecks withauth.jwt()+ RLS policies. - Live opponent progress is not broadcast. The passage highlighting on each lane is local only. If you want an opponent progress bar, add a broadcast channel via Supabase Realtime presence.
- No rate limiting. Add
@vercel/firewallorupstash/ratelimitmiddleware before a public launch. - Venue Wi-Fi matters. Every keystroke submission round-trips over the internet. The venue network should be reliable for gameplay.
typing-race/
├── app/
│ ├── layout.tsx
│ ├── page.tsx # Home
│ ├── signup/page.tsx
│ ├── lane/[id]/page.tsx # /lane/1, /lane/2
│ ├── leaderboard/page.tsx
│ ├── admin/page.tsx
│ ├── final/page.tsx
│ ├── results/page.tsx
│ └── api/
│ ├── state/route.ts # full snapshot
│ ├── players/route.ts
│ ├── queue/route.ts
│ ├── submit/route.ts
│ ├── race/finalize/route.ts # idempotent, public
│ ├── final/finalize/route.ts # idempotent, public
│ └── admin/… # token-gated actions
├── components/
│ ├── TopBar.tsx
│ ├── api.ts
│ └── useAppState.ts # Realtime hook
├── lib/
│ ├── supabase.ts
│ ├── scoring.ts
│ ├── passages.ts
│ ├── state.ts # server-side data layer
│ ├── auth.ts
│ └── types.ts
├── supabase/
│ └── schema.sql # paste-run in Supabase SQL editor
├── .env.example
├── next.config.mjs
├── package.json
├── tsconfig.json
└── README.md