CricLot — a real-time
auction engine
Host IPL-style private cricket auctions with team budgets, live bidding, and separate host / team / display consoles — every screen driven by one server-authoritative source of truth over WebSockets, with the concurrency rules covered by an end-to-end suite in CI and a one-command deploy path.
The problem
A live auction is only fun if everyone sees the same thing at the same instant.
In a private cricket auction, a host runs the room while each team bids for players against a fixed purse. The hard part isn't the UI — it's consistency. Multiple people bid on the same lot within milliseconds, a countdown timer is ticking, and three different kinds of screen (host controls, team bidding, big-screen display) all need to agree on the current lot, the highest bid, remaining purses and squad sizes.
Naive approaches break down fast: client-side timers drift, HTTP polling shows stale state, and letting clients compute anything invites double-bids, over-budget purchases, and squads that exceed their cap.
The approach
I made the server the single source of truth. Clients never decide anything that matters — they send intents ("place bid", "start lot") and render whatever state the server broadcasts back. One authoritative process owns the timer, the current lot, every purse and every squad.
- Server-authoritative engine — a lot state machine drives the auction; the hold timer resets on each valid bid, and the server alone decides when a lot is sold.
- Concurrency-safe bidding — every bid is validated against purse, minimum increment and squad cap before it's accepted, then persisted, so two near-simultaneous bids can't corrupt state.
- WebSocket fan-out — a hub pushes each state change to every connected screen at once, so host, team and display consoles stay in lock-step with no refresh hacks.
Architecture
Clients send intents; the engine decides; the hub fans state back to every screen.
Key decisions
The timer lives on the server
Clients only render the countdown they're told about. Because the authoritative timer resets on each accepted bid, last-second sniping works exactly as players expect and no screen can disagree about how much time is left.
Validate before you broadcast
A bid is checked against purse, increment and squad cap before it becomes state. Only accepted bids are persisted and fanned out, so an invalid bid never flickers onto other screens and can't leave the auction in a bad state.
One message shape for every screen
Host, team and display consoles subscribe to the same state stream and just render their slice of it. That kept the protocol small and made adding a new kind of screen a front-end-only change.
Roles resolved on the server, not the client
The seat endpoint resolves host, team or display from a JWT or an access
key, so a read-only board can join a room without an identity while host-only controls
return 403 for a team key. Authorization is a server concern; the front end
just hides what it was never allowed to call.
Scaling out would break correctness — so it doesn't
The timer runtime and the WebSocket hub are in-process. A second worker would fork a live auction into two conflicting truths, so the deploy configs deliberately pin one instance and one worker. Horizontal scale here needs shared timer state and a broker-backed hub first — that's a real trade-off, documented rather than hidden behind an autoscaler.
Deterministic shutdown beats a passing-most-of-the-time suite
Dropping an auction runtime used to cancel its timer tasks without awaiting them, so a timer caught mid-query was still unwinding as the event loop closed — deadlocking the end-to-end suite about half the time during teardown. Awaiting cancellation, disposing the test engine while its loop is alive, and reading test WebSocket frames through a bounded queue means a missing broadcast now fails instead of hanging.
Delivery & testing
A real-time system is only trustworthy if the concurrency rules are verified rather than asserted, so the auction's hard edges are covered end to end:
- End-to-end suite — full host flow, multi-client WebSocket broadcast, hold-timer reset plus unattended auto-settle, purse / increment / squad-cap rules, and the host / team / display authorization matrix.
- WebSocket load script — 50 concurrent sockets stay connected and every one of them sees each bid within ~5 ms p50 / 16 ms p95 of the first subscriber.
- CI on every push and PR — the suite runs on GitHub Actions, so timer and broadcast regressions surface before merge.
- Hostable, not just runnable — Dockerfile plus Fly / Railway configs for the API and Vercel for the front end, or LAN / Cloudflare Tunnel for a living-room auction; an architecture doc and a 2-minute demo script ship alongside.
Outcome
Measured with the bundled load script against a single instance — the topology the deploy configs actually pin.