All projects
Systems Build● Live★ Featured

Pyramids Queue

Designed and built four production systems for a 500-player esports circuit: a Discord bot, a Flutter staff platform, a Next.js public site, and the Postgres schema underneath all three.

Pyramids Queue runs the MENA Wild Rift circuit that feeds Rift Legends, the EMEA league. 536 players, 195 matches, 4,394 hours watched in a single edition. Coordinated by one person across a spreadsheet, a Discord server, and a group chat. I built the stack that replaced that, and I run the tournament on it.

pyramidsqueue.com
4
production systems on one Postgres database
536
Players, one edition
195+
Matches operated
147K
Live views
4,394
Hours watched

Three problems worth describing

These are the ones that were not obvious from a specification, and they are the ones I would talk about in an interview.

Wild Rift scoreboards print no champion names

Only a circular portrait. Everything about extracting them is image recognition rather than text, so the prompt instructs the model to return blank when unsure. Returning nothing is correct behaviour; returning a plausible wrong champion is the failure.

Copy-pasting a Discord message destroys the data in it

A mention renders as @name in the clipboard, and the numeric ID is gone. Registrations were losing every player ID silently, so roles were never granted and nobody could see why. Fixed by parsing the real mention and adding a way to pass the original message link instead of its text.

The same substring bug in two alphabets

A referee helper matched keywords to suggest rulebook answers. includes('sub') matched "how do we submit the result" and answered a results question with roster rules. Fixing it with word boundaries then exposed the Arabic version, which is worse: \b is defined on ASCII characters so it never matches at the edge of an Arabic word, and Arabic joins the definite article directly to the noun, so النت (the internet) is a prefix of النتيجة (the result). Both found by writing the test cases out, not by reading the code.

What I did

Designed the data model first

Four systems share one Postgres database. The bot writes, the staff platform verifies, the public site reads. The alternative, each system with its own store and a sync between them, is where the original problem came from, so it was ruled out before anything was written.

Made row level security the privacy boundary, not the application

Seven circuits on one deployment, each seeing only its own data, enforced in database policies. The public site reads nine public_* views rather than any table, so Discord handles, staff notes and unverified statistics cannot reach a visitor by construction rather than by remembering to filter.

Built the verification gate

Model-extracted player statistics land with verified_at empty and the public view filters them out until a person approves them in the staff app. The filter is in the view, so a future page cannot bypass it.

Wrote the Discord bot

Ten commands, TypeScript. Registers a team from a pasted roster, reads a Toornament bracket screenshot into a bracket, creates a private channel per match with the right roles, DMs every player a link, and reads end-of-game scoreboards into statistics.

What I would do differently

The public site and the season page were built as two implementations of the same standings table, three weeks apart. They drifted, and one of them never got the mobile work, so it forced a horizontal scrollbar on every phone. Merging them into one component was ninety lines deleted and the bug fixed at the same time. The lesson is not "reuse components", which everybody already knows. It is that the second implementation existed because I forgot the first one, and the fix was a search before a build.

Tools & Tech

TypeScriptNodediscord.jsNext.js 15React 19TailwindFlutterRiverpodSupabasePostgres with row level securityGemini for image extractionVercel