Sports scoring sounds simple. One team scores a point, the number goes up, everyone sees it. But when you build it as a web application that needs to work on courtside tablets, spectator phones, and wall mounted displays simultaneously, with voice commands and tap controls, the architecture becomes more interesting.
The project was Scoring AI, a voice enabled match scoring application for sports courts. Players start a match, share a link, and control the scoreboard from any device. The backend handles real time state synchronization, optimistic locking, idempotent score updates, rate limiting, and WebSocket broadcasting.
The team was small. Me and a coworker who handled the CI/CD side. We were at the same level, both full stack, and we designed the system together. He focused on the deployment pipeline and infrastructure automation. I focused on the application layer, the real time system, and the frontend. But the architecture decisions were shared.
This article covers the technical decisions we made and how patterns from previous projects influenced them.
The match scoring data is different from the business data around it. A match lasts about an hour, gets updated frequently, and needs to be read by many viewers at once. After the match is complete, it is archived and rarely accessed.
I had seen what happens when you put high frequency state updates into a relational database on a previous project. Row locks, contention, connection pool exhaustion. For Scoring AI, we used DynamoDB for the live match state and PostgreSQL for everything else.
The hot path needed fast writes, optimistic locking, and automatic cleanup of abandoned matches. DynamoDB provides all of these. The version
field on each match record acts as an optimistic lock. Every score update is a conditional write that checks the version has not changed.
The cold path uses PostgreSQL through Kysely for user profiles, subscriptions, pricing plans, payment history, and match archives. My coworker handled the payment integration with Paystack.
The two systems communicate through DynamoDB Streams. When a match completes, a stream subscriber archives the result to Postgres.
Spectators see score updates in real time. No polling, no refresh.
The design uses three decoupled components. The REST endpoint handles score updates and writes to DynamoDB. It does not know about WebSockets. The DynamoDB Stream fires on every write to the table. Two Lambda functions subscribe to it independently.
The broadcast Lambda reads the updated match, queries the WebSocket connections table for all spectators, and sends the update using the API Gateway Management API. Connections expire after 24 hours.
The archive Lambda reads completed matches and writes them to PostgreSQL inside the VPC.
Adding a new consumer of match events is just writing a new Lambda that subscribes to the same stream. This pattern was inspired by the SQS based decoupling we used on another project.
The scoring engine lives on the frontend as a pure TypeScript module. It takes a match state and a scoring action and returns a new state. No API calls, no database reads.
This approach came from a lesson learned on a previous project where round trip latency made the UI feel slow. For a scoring surface where a tap needs to feel instant, local state calculation was the right call.
The engine supports four scoring structures: rally, sideout, tennis, and pickleball. Each has different rules for points, sets, and tiebreaks.
The match point handling is the most subtle piece. When a team reaches match point, the engine opens an eight second confirmation window. If the same team scores again, the match is confirmed. If the opposing team scores, the match ends immediately. If neither happens, the timer auto confirms.
The voice command integration uses the browser SpeechRecognition API. The system maps team names, colors, and NATO phonetic aliases to scoring actions. A cooldown of 850 milliseconds prevents double taps. Audio feedback tones confirm the command.
Chrome's SpeechRecognition stops firing after a few consecutive identical utterances. We documented this and added a manual text input fallback.
I needed rate limits but did not want to add Redis or ElastiCache. A separate caching layer adds cost and maintenance. I had seen teams struggle with cache clusters on other projects.
The solution was a DynamoDB backed token bucket rate limiter. The bucket key combines the action, the identifier, and a time window. Score updates are limited to twenty per ten seconds per match.
The rate limiter fails open. If DynamoDB returns an error, the request is allowed through.
My coworker handled the CI/CD pipeline. He set up the cross account CodePipeline and the CodeBuild orchestrator. I focused on the application infrastructure: VPC, database, API Gateway, WebSocket endpoints, DynamoDB, SQS, and Lambda configuration.
One decision that made a difference was separating the function configurations. Most Lambda functions run inside the VPC for database access. The match scoring functions do not. They only access DynamoDB. Putting them outside the VPC cut cold start time from several seconds to under two hundred milliseconds.
Scoring AI was built by two people at the same level, each owning different parts of the system. The patterns we applied came from previous projects. The database separation came from watching a relational database struggle under write load. The decoupled stream architecture came from SQS based systems on an earlier project. The frontend scoring engine came from latency lessons learned elsewhere.
Each project teaches you something that applies to the next one. That is the main thing I have taken away from building multiple systems over time.