Here is a product constraint that turns out to be an engineering one. A pub quiz has forty to a hundred players. They arrived to see their friends. They will not install an app, they will not create an account, and if the thing you ask them to do takes more than about ten seconds they will put the phone down and shout answers at whoever did manage it.
So on pub-trivia.app the entire join flow is: point camera at the card on the table, tap the banner, type a team name. That is it. No download, no code to type, no email.
Getting there is mostly a set of refusals, and each one removes an entire category of code.
The URL carries the identity
const playerUrl = `${SITE_URL}/play/${venueId}/${tableId}`
Two ids in the path, one QR code per table, printed once and left there. The URL is the credential and the routing at the same time, and that has a consequence worth pausing on: the card knows which table it is on.
A single "join the quiz" code for the whole venue would be simpler to print and worse in every other way. With per-table codes you get seating for free. The host's screen shows "Table 7 has joined" rather than a list of nicknames with no relationship to the room, a challenge about a score can be settled by walking to a table, and the leaderboard names map to physical places.
The generation is the boring part, done once when a table is created:
async function generateQrCode(venueId: string, tableId: string) {
return QRCode.toDataURL(`${SITE_URL}/play/${venueId}/${tableId}`, {
width: 512,
margin: 2,
color: { dark: '#000000', light: '#FFFFFF' },
})
}
Two things there that are not decoration. margin: 2 is the quiet zone, and without enough of it a scanner struggles against a busy printed background. Pure black on pure white, rather than brand colours, because contrast is what a camera in a dark pub actually needs. A tastefully amber QR code on a dark card is a product decision that costs you scans, and you will never see the failures, because they are people giving up.
The codes have to arrive as a print job
One QR code is a download. Sixteen table cards is a print job, and that is what a venue actually needs on a Tuesday afternoon:
const LAYOUTS = {
'1': { cols: 1, rows: 1, label: '1 per page' },
'2': { cols: 1, rows: 2, label: '2 per page' },
'4': { cols: 2, rows: 2, label: '4 per page (recommended)' },
'6': { cols: 2, rows: 3, label: '6 per page' },
'9': { cols: 3, rows: 3, label: '9 per page' },
}
A4, generated in the browser with jspdf, pulled in by a dynamic import() so a large dependency behind one button is not in the initial payload. Four per page is marked recommended because that is the size that survives being laminated and read across a table.
This is the part of "make it easy to join" that lives in a print layout rather than in the app, and it is at least as important as anything on the socket.
The player page has to work before anything is happening
A player scans at 19:40. The quiz starts at 20:00. If the page says "no active session" and stops, they have learned that your app is broken, twenty minutes before you need them to trust it.
So the scan lands on a lobby: the venue, the table, a team name field, and a live wait for the session to open. The URL is stable and permanently valid, because it identifies a table rather than an event. Scan it next Tuesday and it still works.
The same reasoning applies mid-quiz. Someone who arrives during round three, or whose phone locks and reconnects, must land in the current state rather than on a blank screen. Our WebSocket handshake sends a state snapshot immediately after auth: current question, remaining time, leaderboard. Real-time systems need "here is where we are" as well as "here is what changed", and the join flow is where that becomes obvious.
Everything about the burst is a rate-limiting problem
A hundred phones on one venue WiFi, scanning inside the same thirty seconds, all leaving through one NAT exit IP. To a limiter keyed on IP, that is indistinguishable from an attack.
We sized the join limiter for the room rather than for a person:
joinSession: sliding(100, 60), // per IP: an entire venue joining at once
playerPageLoad: sliding(60, 60), // per IP: the initial scan
submitAnswer: sliding(10, 60), // per participant, not IP
sessionPoll: sliding(30, 60), // per participant, not IP
The split matters. Before a player has joined there is no identity to key on, so joins and page loads are IP-keyed and generous. Afterwards there is a participant id, and everything keys on that, because an IP-keyed answer limit is not a limit on a player, it is a limit on the room: five busy tables would throttle the other thirty-five.
No account, but not no identity
Players never sign up. A participant record exists for the session and nothing else, and correctness comes from the database rather than from trust:
UNIQUE (participant_id, question_id)
One accepted answer per participant per question, enforced where it cannot be argued with. That constraint is why the answer rate limit can be as low as ten a minute: it does not have to prevent double answers, it only has to cap cost. When people conflate those two jobs they end up with limits that are either useless or punishing.
The refusals, listed
- No app. The web is the install step, and it is already done.
- No account. A team name is the only thing we ask for, and it is the thing they want to choose anyway.
- No join code to type. Typing a six character code in a dark pub is the step where a third of the room gives up.
- No per-event QR. Codes are per table and permanent, so nobody reprints anything on the day.
Each refusal deletes a screen, and each deleted screen is a few percent of the room that stays with you.
Try the scan
pub-trivia.app/features/qr-code-quiz-joining describes it from the host's side. The honest way to evaluate it is with two devices: the free tier needs no card, so create a venue and a table, print or just display the code, and scan it with your phone. From camera to typing a team name should be about ten seconds.
If you only want the QR part, our generator makes one for any URL, in the browser, with nothing sent anywhere.
Top comments (0)