A full review of the backend, the website, the app and the LAN phone-server, run before launch and before the app is put in front of a crowd. Twelve reviewers worked in parallel, then a second round tried to disprove everything the first round claimed.
You asked for the app to survive a sudden rush, not just to be secure. Three problems would only ever have shown up under exactly that pressure β which is the worst possible time to discover them, and the reason none of our testing found them.
Every time anyone started a game, the app wrote the play count for that game into a single place. One writer at a time is fine. A thousand people starting Tambola in the same minute all queue for the same line, and the ones who lose the race get an error instead of a game.
Now: the tally is split across 16 separate lines per game, picked at random. Sixteen times less queueing, and it never gets worse as more people arrive.
The home page's "Trending" row watched those live counts. So every game started anywhere in the world made every person on the home page recalculate the list. Ten thousand visitors plus a steady trickle of games is a flood the backend creates for itself β the classic way a busy launch takes itself down.
Now: the list is worked out once every 10 minutes and saved. Visitors read that one saved answer. Traffic no longer causes work.
A daily job deletes finished rooms so codes can be reused. It limited how many rooms it looked at, but not how much it deleted. A batch of unusually busy rooms could exceed the allowed work, get cancelled, and be retried identically β forever. Old rooms would pile up and eventually you'd run out of room codes.
Now: it counts what it deletes, stops at a safe point, and picks up exactly where it left off.
scripts/build-site.mjs into the folder Cloudflare serves.| What was wrong | Why it mattered | Where |
|---|---|---|
| A player leaving mid-game could freeze it permanently | Each game must say what happens when someone leaves (some can rebalance, a two-player duel cannot). One older "leave" path skipped that rule and just removed the person β leaving the game waiting forever for someone who was gone. No attacker needed: tapping Leave at the same moment the host taps Start was enough. | convex/rooms.ts |
| A dead contact form was still accepting writes | The contact page opens your email app and always has. But an old, forgotten submission endpoint was still live and open to the whole internet, and its only anti-spam check could be defeated by changing one word. It had no callers. We deleted it rather than hardening it β the right fix for a door nobody uses. | convex/contact.ts (removed) |
| Names could be faked to look identical | Room titles were cleaned of invisible characters; player names were not. So "Karim" and "Karimβ " (with a hidden character) counted as different names but looked the same in the roster β and text-direction characters could scramble the layout for everyone. Now names get the same cleaning, and the duplicate check compares them properly. | convex/lib/roomTitle.ts |
| Unlimited-length session IDs were stored | The ID a guest's browser uses to hold their seat was written down at whatever length it arrived. A huge one would bloat every read of that room and could jam the cleanup job. Now bounded before it is stored. | convex/lib/access.ts |
| Shared links previewed as bare URLs | Your whole growth plan is people pasting links. Every one of them β the social post, and every room invite in a group chat β unfurled as plain text with no picture and no title. Room links had no page title at all. Now there is a branded preview card, each game page previews with its own poster, and room links say "join the room". | site + +html.tsx |
| Blank screen while fonts downloaded | The web app refused to draw anything until several megabytes of fonts arrived β so a stranger tapping your link on mobile data stared at nothing. Now the web draws immediately and the fonts swap in. (The phone app still waits, correctly: its splash screen is already covering that moment.) | _layout.tsx |
| No security headers at all | Added the browser-level protections: your pages can no longer be secretly framed inside another site (a way to trick taps), the browser refuses to guess file types, HTTPS is enforced, and the page is only allowed to talk to our own backend β so injected code would have nowhere to send anything. | build-site.mjs |
| Developer-only screens were publicly reachable | Internal galleries (/fx, /catalog, /gate,
/caller-preview, /lan-spike) plus an auto-generated index
listing every screen were served on the live domain. Their pages are now deleted while
the site is assembled, and the build fails if one survives. They still work while
developing. |
build-site.mjs |
| The room code was the only thing protecting a room | Anyone holding a code could take a seat, and at four characters there are only about 810,000 of them β few enough for a script to sweep and walk into whichever rooms happened to be open. The answer we shipped was not a longer code but a door: every room is private, and holding the code only earns the right to knock. Nobody is seated until the host taps Approve, so a swept code now buys a request somebody declines. Codes stayed at four characters. Ships with your next app build. | convex/rooms.ts Β· packages/engine/src/room.ts |
| LAN: the host's identity was reused | The secret proving "I am the host, let me start or end the game" was the same ID the phone sends in the clear whenever it joins someone else's LAN game. So a value already broadcast on past Wi-Fi networks doubled as the host's password. Now a fresh random one is minted for each hosting session, and it is never stored. Next app build. | lan/hostSession.ts |
| LAN: one connection could hold several seats | A single browser could claim seat after seat, and hanging up only released the last one β the rest sat in the player list forever. Both release paths now share one piece of code, so they cannot drift apart again. Next app build. | lan/hostSession.ts |
| Broken install prompt | "Add to home screen" offered a nameless app with a white loading screen against a dark game. Fixed name, icons and colours. | site.webmanifest |
What remains is one deliberate size question, one real gap the knock gate does not close, and one limit we chose to live with. (The two LAN items that used to sit here shipped: a fresh host identity per hosting session, and one seat per connection β see the fixed table above.)
| Item | Risk today | Why it waits |
|---|---|---|
| A swept code still reveals that a room exists and its title, host name and player count |
Low, and unchanged by the knock gate. The lookup a join screen does needs no sign-in, so a script sweeping all ~810,000 codes learns which rooms are live right now and reads each one's title, host name and how many people are in it. It cannot get in β that still takes a host tap β and it cannot see any gameplay. But room metadata is real information, and nothing limits how fast the sweep can go. | Rate limiting is the fix, not a longer code. Cap unauthenticated room lookups and knocks per device and per address before the app is promoted; a sweep that has to crawl learns nothing useful. Deferred only because there are no real users yet. |
| A turned-away guest can knock again from a new browser the same session cannot |
Low, by design. Keep out refuses the same session on that room (2026-09-14), and the server enforces it at the knock. A guest is an anonymous device, so a private window is a new session and knocks again. Every knock still needs a host tap, so the worst case is a repeated row the host turns away. | Guest accounts would make a ban keepable. Until then, the knock rate limit below is what stops one person filling the host's door. See decisions. |
| Dev screens still inside the download measured: 211 KB of 3.6 MB |
None. They are unreachable in public β this is file size only. | Measured and deliberately declined. Removing them shrinks the download by 6% (~60 KB compressed), which does not change how the app feels. The only way to get it while keeping the galleries usable in development is to restructure five route files β real risk, no user-visible gain. Revisit if the bundle is ever attacked properly, where this is a footnote next to the other 3.4 MB. |
scripts/build-site.mjs so nobody "fixes" it by accident and breaks play.| Stage | What happened |
|---|---|
| Six specialists | Sign-in & permissions Β· data handling Β· scale under load Β· the LAN phone-server Β· the website Β· abuse & spam. Each read the real code, not a summary. |
| Six challengers | Each finding was handed to a reviewer told to destroy it β open the file, prove the claim wrong, or prove the risk was overstated. 21 of 37 died here, including several that sounded alarming. |
| Then verified | Every fix was type-checked and tested, the site was rebuilt and its routes checked in a browser, and the backend jobs were run once against production to confirm they behave. |
Judged against the real world, not a worst case: friends and family playing together, a host who invites people, and LAN games played by people in the same room. Findings that only made sense against a determined stranger attacking a bank were marked down accordingly.