項目作品/SportScore
A live scorekeeper for pickleball and badminton: tap to score, automatic serve tracking, score calls, in-game sound effects and match summaries.
The problem. Keeping score by hand during a pickleball or badminton match is easy to get wrong, especially in doubles, where the server and the side change after almost every rally.
What it does. SportScore is a mobile-first scorekeeper for both sports. You pick a sport and a format (singles or doubles), enter the players (or play as a guest), and score by tapping Point or Fault. The app tracks who is serving, handles side-outs and works out the score call for you. Pickleball follows side-out scoring to 11, win by 2. Badminton uses BWF 21-point rally scoring.
Highlights.
Key architectural decisions, concrete code implementations, and performance wins behind this project.
Doubles pickleball changes server and side after almost every rally, and badminton uses a different system (BWF 21-point rally scoring with a 30-point cap). Scoring logic buried inside UI components is hard to trust and impossible to unit test.
Each sport's rules live in a pure engine module (state in, new state out) with no React or I/O. The engine handles side-out and server number, win-by-2 as a toggle, wrong-serve rules (USAP fault or recreational replay), and snapshot-based undo. Controllers, the overlay and spoken score calls are all views of the same state.
Streamers and players want the score on the video itself, either over a phone camera feed or as a transparent layer in OBS. Browsers have no built-in way to merge a camera feed with live DOM UI.
A shared view-model adapter converts either sport's state into one overlay shape. A canvas composites the camera frame and the score bug at 1080p, and MediaRecorder captures the canvas stream with microphone audio. It picks VP9, then VP8, then plain WebM depending on the browser. A separate overlay page serves OBS as a browser source, kept in sync through BroadcastChannel.
Courts have bad signal, so scoring cannot depend on a server. The controller and the overlay still need to stay in lockstep, and games need to survive reloads and be resumable later.
State is the source of truth in localStorage with an indexed multi-game session store (short IDs like #K7P2, in-progress games first, automatic migration of legacy single-game saves). A typed BroadcastChannel bus syncs the controller and overlay tabs, with a storage-event fallback where BroadcastChannel is missing. Sessions then sync to the Rust server when the user is online or signed in.
Two scorekeepers can invite the same player, or fill the same slot, at the same moment. Checking in application code first leaves a race window. Expired invites that still read 'pending' can also block new ones.
The server locks the session row with SELECT ... FOR UPDATE inside a transaction, expires stale invites, then inserts. Partial unique indexes make the invariant impossible to violate even if the application logic has a bug. Pure rule functions (slot validation, lockout, re-invite wait) are kept free of I/O so they can be unit tested. Every rejection returns one neutral message, so a scorekeeper cannot tell a decline from a lockout.
Spectators and invited players need live updates, and the server may run as more than one instance. Redis should not become a single point of failure for local development or small deployments.
The Axum server runs an in-process tokio broadcast channel for session and user events, fed by a dedicated Redis pub/sub subscriber when a Redis URL is configured. If Redis is missing or unreachable, the hub logs a warning and runs in degraded single-node mode instead of crashing. Edits to a shared session are guarded by a hashed edit key checked in constant time, plus a version number.
Once results feed profiles, milestones and tournaments, junk games (a 21-0 clicked through in seconds) would pollute the stats.
The server flags games that finish in under two minutes both on creation and on finish. Flagged games are excluded from official win/loss stats, and an admin dashboard reviews them and verifies tournament results. A client-side milestone engine turns verified history into XP, levels, streaks and unlockable achievements.
Reading scores aloud through the Web Speech API sounded robotic. Scores like '4-2-1' were read as dates. Chrome could cut speech mid-sentence, and sleeping Bluetooth speakers swallowed the first word.
A small speech module ranks installed voices to pick the most natural English one and re-picks when voices load late. It keeps a reference to the active utterance so Chrome cannot garbage-collect it mid-speech, and cancels overlapping calls. After a six-second pause it prepends a short audible lead-in, because silence does not wake a Bluetooth speaker but an audible word does.
The cockpit must work on a phone held in landscape on a court, on a laptop, and installed like a native app, without maintaining three UIs.
A single Next.js app serves all three. The web manifest gives a standalone landscape PWA. A static export (STATIC_EXPORT=true) is wrapped by Capacitor into an Android app whose manifest locks sensorLandscape and requests camera and microphone permissions. Cross-browser fullscreen and orientation locking cover browsers without the Screen Orientation API.
// community_discussion
Have thoughts, questions, or ideas regarding this system's architecture? Leave a comment below with your GitHub account.