Event PassEvent Pass
Purdue's Boiler Book Club hands out free books and other book themed goodies to 300+ people at events. We originally used paper numbers that we'd shout out in groups, and Google Form nobody ever remembered to fill in to track goodie stock. Now it's three screens reading one live document: a ticket on every attendee's phone, a projected display for the room, and a control panel for staff.
Why does a giveaway need a server?
A giveaway event is a DMV that gives you something nice at the end. Everyone takes a number off that rolly ticket dispenser thing, groups get called up to the front, and everybody else waits. Slowly. Very slowly.
It makes sense why. Run it by hand and you get a clipboard, one person shouting over a crowded room, and a paper list of who has already taken a book. What you don't get is an answer to the only question anyone in that room is actually asking: how many people are still ahead of me?
Nobody knew. Not the staff, not the attendees, and definitely not the Google Form.
Attendees lost their slips too, even after many reminders to hold onto them, and nobody's memory of what number was up matched anybody else's. So I replaced all of it with three screens that read and write the same live event document. An attendee's ticket flips the instant staff call their number, and several staff members can run the same event from different devices without stepping on each other.
Three screens, one document
- Attendee ticket: they check in by scanning the display and signing in with Discord, which gets them a numbered ticket. The ticket turns itself into a scannable claim QR the instant their number is called, and hides it again once it's been scanned.
- Room display: the current round, the number range that's up, whether it's a final call, a rotating check-in code, a live activity feed, and a prize raffle wheel for when staff want to give away something extra.
- Staff control panel: call groups, auto-advance, rewind, scan claims with the device camera, manage the roster and pre-event queue, run raffles, watch turnout graphs, and archive the event when it ends.
How I built it
The front end is React 19 and Vite, with Firebase behind it. Firestore holds a live event document with claims, queue, and feed collections. Everything the client isn't allowed to decide lives in 27 Cloud Functions: 21 callables, plus Firestore triggers, two scheduled jobs, and a crash-report endpoint.
That split is the architecture. Numbers, membership, staff status, and eligibility all come from a verified token on the server, and the browser only gets to pick which screen to draw.
To match the club's vibe, the UI is built on rough.js and its wired-elements, which give everything a hand-drawn sketchbook look. Even the crash screen.
- Built a rewind that steps backwards through the group, the previous group, round-not-yet-started, and the previous round's final call. It exists for staff misclicks, not second helpings: the server tests eligibility as "claimed in this round or a later one" rather than trusting the UI, so nobody who already grabbed a prize can rewind their way into another.
- Wired up auto-advance with a claimed-percentage threshold, a group timer, a next-round timer, and a final-call timer, plus a backlog limit that holds everything until the staff table catches up with the people already called (that one is my favorite). It runs in the staff control panel, not on the server, which is a real tradeoff: close every control panel and the event politely stops advancing. For a room that always has staff at the front, I'll take that over yet another scheduled function.
- Stored staff numbers as negative integers (shown as S1, S2, and so on). They sort ahead of #1, the group-call window can never reach them, and every query on a positive number excludes them for free.
- Built a demo mode that drives up to 300 simulated attendees through staff-only server callables that share the production transaction and eligibility code, so I could rehearse how the queue behaves and use it to train new staff. It refuses to touch any event that wasn't created as a demo.
The attendee is evil
Design assumption, stated plainly: every attendee is a signed-in user, on a device I don't control, standing in a room where free books are being handed out.
They will try anything. ANYTHING to get an extra book. Almost everything below is what taking that seriously looks like.
- Used physical presence as an auth factor: the display shows a QR code built from a staff-only secret and a 60-second time bucket, so opening the site directly gets you a wall, not a number. I widened the server's accepted window to six buckets after the three I shipped first turned out way too narrow. Someone just walking in has to finish a whole Discord OAuth on venue Wi-Fi shared with 300 other phones, so the tight window was closing the gate on people already through it. (It's a keyed 32-bit hash, not an HMAC. The goal is "you had to be in the room", not "withstand a nation-state", and a code that dies in six minutes is plenty for a book giveaway.)
- Moved login off the implicit grant, which had been leaving a real Discord access token in every attendee's localStorage for 24 hours, to an authorization-code exchange with PKCE. Nothing Discord issues ever reaches the browser now, and the old tokens get actively deleted from anyone who still has one.
- Filtered display names and avatar URLs on the server. Names go through a profanity and impersonation filter (sorry, nobody gets to be "admin"), and avatars have to come from Discord's CDN. A filter the client enforces is one direct callable away from being skipped, and the value ends up on a projector in front of a room.
Zero traffic, then everyone at once
The load profile is zero traffic, then the entire user base hitting one document at once, then zero traffic again. What a fun problem.
Number assignment runs in a transaction against the live event document that holds the counters, and a transactional read takes a lock. The catch turned out to be who else was taking that lock: attendees who already had a number were going through the same transaction on every reload, retry, and ticket reopen, contending with every brand-new check-in for no reason at all. Now a returning attendee gets two point reads and a lock-free batch, and only someone without a claim falls through to the transaction.
- Raised the hot callables to 60 max instances, which did nothing until I gave them a full vCPU. Below one CPU, Cloud Run refuses to run more than one request per instance, so sixty instances just meant sixty concurrent check-ins and sixty cold starts landing on the first people through the door. With a full vCPU, each instance takes 20 at a time.
- Found the display's activity feed was one array document rewritten in a transaction per claim, which put the whole room behind a single contended write. Split it so each item is its own document, trimmed on a schedule.
- Discovered four sweeps had quietly grown past Firestore's 500-operation commit limit, including the one that converts the pre-event queue when doors open. That one failed for the entire event once ~250 people were waiting, leaving nobody with a number. All four are paged at 200 now, and the queue sweep uses a cursor so a skipped entry can't send it around in circles forever.
- Caught the QR code being rebuilt from scratch on every render, in a component that re-renders once a second for its clock. Memoizing on the payload was a one-line fix worth 3.9 ms and 137 KB of garbage per render (measured on the old, bigger payload; more on that below).
Error correction lied to me
I knock the attendee's number out of the middle of their own QR code, which sounds completely fine, because error correction level H is advertised as surviving 30% damage. A hole in the middle is nowhere near 30% of the code. Sound perfect, right? You might as well skip this section.
Here's the issue: that 30% isn't one budget. It's split across Reed-Solomon blocks, and each block corrects on its own. A hole in the middle is one contiguous blob that interleaving doesn't spread evenly, so the worst block took 16 damaged codewords when it could only fix 15.
Every attendee numbered 100 or above had a ticket that couldn't be scanned. Whoops.
I compressed the payload from 207 bytes of JSON down to 105 bytes of pipe-separated fields, which dropped the code from version 16 to version 10, drew every module about 40% larger in the same box, and left the worst block at two thirds of its budget. The tests now measure damage per block against the same encoder react-qr-code uses, and a regression test asserts the old geometry still fails, so nobody can "simplify" it back.
You can't debug it night of
There's no debugging anything on the night of an event. If you can, I salute you. That set the bar for how much had to be true beforehand: 221 automated tests across four layers, a load-test harness, and a development loop that runs entirely offline.
- 151 pure unit tests over the auth step machine, claim access codes, QR payloads, raffle weighting, retry classification, and the name and avatar filters.
- 69 Firestore rules tests against the emulator, gating every deploy. The rules are the only thing between a signed-in Discord user and the event's data, and a bad edit is invisible until someone can't claim a number.
- A boot smoke test that evaluates the whole client module graph in JSDOM, because a successful vite build isn't evidence the app can start.
- The full system runs against local Firestore, Auth and Functions emulators with a seed script, plus three fake logins (staff, member, guest) that the server only honors when it's running in the emulator, a branch that can't run in a deployed function.
- A load-test script that fires a crowd of check-ins at a non-production project, reports latency percentiles and errors by code, and asserts that every attendee got a distinct number and the counter landed where it should. It refuses to run against production, because I know myself.
- GitHub Actions runs lint, unit tests, the smoke test and rules tests before anything deploys, with production behind a protected environment. Deploys write the Functions runtime config from repo configuration, after a real bug where a gitignored env file meant CI deploys silently fell back to code defaults. The pipeline also fails if App Check enforcement is switched on without a site key, since that would lock out every attendee at once.
What I would do differently
Most of the work above fixes something I got wrong the first time. The lock contention, the 500-write ceilings, the too-narrow check-in window, the unscannable three-digit tickets: every one of them was findable before an event rather than during one.
That's why the load test and the 300-person demo mode exist now, and why I'd build them first next time.
It's still a DMV. The line still moves exactly as fast as the people at the front of it. But it moves, and everyone standing in it can finally see how far away they are to those sweet sweet free books.