โ† All docs Games for Group ยท Architecture

Online Backend (Convex)

The cloud referee that powers Online mode: rooms, players, and live game state. Its defining property: it is completely generic โ€” it has no idea what tic-tac-toe is, yet it referees it perfectly.

โ˜๏ธ One generic backend runs every game โ€” adding a game adds zero backend code. And one iron deploy rule: the backend ships before any app code that needs it.

What Convex is (in plain words)

Convex is a hosted backend service: a database plus small server functions that read and write it. Its superpower is live queries โ€” any screen can "subscribe" to a piece of data, and the moment that data changes, the screen updates by itself. No refresh button, no polling. That one feature is online mode: someone taps a square in Delhi, and the board repaints in London within a blink.

The life of an online room

  1. The host taps "Host" and signs in with Google or Apple. Only hosts ever sign in. Friends never create accounts โ€” each guest's phone just remembers a private random ID (so they can reconnect as themselves later).
  2. A room is born with a 4-character code โ€” letters and digits that can't be misread (no I, L, O, 0, 1 or S). The code is short on purpose: it gets read aloud across a table and typed out of a group chat. The host also chooses how many seats the room holds, which is the cap the server enforces.
  3. Friends knock with the code and type a name โ€” that is the whole form. The code does not let anyone in; it lets them ask. Their name arrives at the door of the host's lobby with two answers (Let in, Keep out), and they sit on a "knockingโ€ฆ the host will let you in" screen until the host answers โ€” mid-game too: the door is open for as long as the host holds a seat, and a late friend let in during play watches until the next start. Approve seats them and hands them a colour from the 8-colour palette (the lowest one nobody is using, so a normal party is all-different and a very big room simply cycles). Keep out sends them away, and that session cannot knock on that room again: their screen says "Not this time", also when they reopen the code. For fifteen seconds the host can Undo it: the note goes and the knock is back in its old place in the queue, so the guest's screen returns to waiting. Only the session is noted, so a new browser is a new knock the host answers again. Remove is lighter, for someone already in: the seat is freed and they may knock again. There is no Ban button. Somebody who is already seated never sees the door again: a reconnect after a dropped signal walks straight back to their own seat, mid-game included. The roster updates live and lists only the people who are actually in. A seat fades when its phone has gone quiet. The seats are on screen in the lobby, and a phone watches who is here, and checks in every 15 seconds, for exactly as long as it is showing them. During a game no screen draws it, so no phone watches it, and a phone checks in every 30 seconds: enough to keep the room alive, and to be found present the moment the seats are back.
  4. The host picks a game and settings in the lobby. Guests see the host's picks change in real time โ€” the selection is synced through the room, not just sitting on the host's phone. What is synced is the whole plan: the game and its answers, a party's running order, and which kind of plan it is (one game, a tournament, a party). A guest can say what they would like to play: one wish each, kept on their own row, shown on every phone in the room (never to somebody still at the door), and cleared when a game starts. A guest is also told when somebody is at the door (never who), so the room can tell the host. For a game that plays from content (questions, prompts, words), the host can also supply that content themselves โ€” type a topic and have it written for them, or write it by hand. It is held for the room, and only the host can read it back: it contains the answers.
  5. Start! The server runs the game's setup() with a fresh seed of the match's own and locks in who's seated. Rooms can hold up to 30 people โ€” if a game seats fewer, everyone else spectates live. If the room has host-supplied content for this game, the server folds it into the settings here, reading it from the database rather than accepting it from whoever tapped Start โ€” a phone can never hand itself a question set nobody vetted, nor read the answer key early.
  6. Every tap is one small server call. The server runs the game's rulebook for that move, saves the new state, and every subscribed phone repaints. Illegal moves are rejected with a friendly error code the app turns into a toast. A game with a clock has one wake-up booked for its deadline. A move that changes the deadline books a new one and cancels the old one in that same save, and a match that ends, or is paused, has none waiting.
  7. Game over โ†’ winner screen โ†’ rematch. A rematch re-runs setup() with a fresh seed and the same seats. The host is shown the game's options again, opening on the answers the finished match was played with: the room has carried them since that match started. A rematch plays the host's new answers โ€” plus whatever the game asked to remember from the match that just ended, so a room playing round after round doesn't get the same questions twice. The host can also end a match early โ€” it's recorded as aborted, and everyone drops back to the lobby.

A room can run a programme

Instead of one match, a room can run a tournament (many two-player matches at once, over rounds, to a podium) or a party (several different games in a row, ending in awards). Both are called a programme. Each of its matches is an ordinary match of an ordinary game, started by the same referee. No game knows it is in one.

A match has a name

A room that plays one match at a time has a current match, and every door (state, moves, pause, leave, rematch) reads that. A tournament holds up to fifteen live matches in one room, so every door also takes the match's own id. It is the same code either way: with no id given, the current match is simply the default.

Who writes what

A move writes its own match and nothing else. The move that ends a match leaves the result on that same match row, marked unread, and books the programme's advance. Only that advance writes the programme and the fixtures, and only at the very end does it touch the room. So thirty people playing fifteen matches never queue behind one document.

Who is woken by what

The bracket reads the programme and its fixtures, never a match. A move wakes only the people looking at that match. A match starting or ending wakes the room once, because only the advance writes what the bracket reads. A fan's pick wakes only the people watching that match, and a fan's own points wake only that fan: each phone reads its own fan's picks, which the advance marks right or wrong as it settles a match. A party's awards show moves a dozen times, so where it is travels on its own and the party itself is not sent again. The bracket travels packed: each player is named once and each match sent once, and the app unpacks it with the same file that packed it.

Nobody drives it

The advance asks what is true now: which matches have ended, who is still in the room, which matches are due to start. It then asks the shared rules what follows. Run twice, late or out of order, it changes nothing the second time.

Who may read it

The bracket, the fan picks and the awards show answer the room's own members and nobody else. Someone holding the code is at the door until the host lets them in, and is told nothing.

What the server never takes from a phone

A host's phone sends its answers (the mode, the board). The server writes the rest itself each time a match starts: who the host is, who runs the table, a party game's short version, who opens a tournament match and how long it is. A tournament match never gets a game's own turn clock, so one clock times a turn and never two. A game that is played on a clock for the whole game (Chess: so many minutes each) keeps it, held to the lengths the game itself lists and never "no clock", whatever a phone sent: a tournament match plays the host's pick or the game's tournament length (three minutes each), and a party plays three. In an ordinary room a match given no answer plays the game's usual length, and only the answer that says "no clock" means none.

MomentWhat the server does
The host starts a tournamentChecks the game seats exactly two and is not parked, that three or more people are in the room and no more than the format takes, and rehearses the game's own setup with the settings every match will get, so a mode that cannot be played this way is refused there and then in the game's own words. Then it draws round one and stores when it starts. Everyone in the room is in it.
A countdown runs outA scheduled step starts every match that is due, all at once: the host's own answers, the opener the draw named, one game a match, and a table nobody runs (the mode is fixed, and there is no next round for anyone to start). The round is counted in the play tally as one write. A match the game will not set up is told to the rules as a draw, which a coin settles there and then, and says so in the logs. Every screen counts down to the same stored moment.
A match endsIts result waits on its own match row. The advance reads it, settles the fixture, asks the rules, and draws the next round or the podium. A match that ended level is settled by a coin in that same step, from the tournament's own seed: nothing is played again, so one fixture is one match. A match that was played to its end stands as played even when its winner then walks out before the advance has run. A match is one game: the move that ends the game ends the match on its own row and stops both clocks, so whatever is sent or scheduled afterwards finds a match that is over.
Someone leaves or is removedIf they were in a match, the game's own rule for losing a seat ends it and the other player goes through. The advance sees the shorter roster and never pairs them again. A late joiner is in the room but not in the draw: they watch.
The host leavesThe room closes, exactly as when the host closes it: whatever is being played stops the way End game stops it (a programme whole, a room's own match frozen where it stands), and every seat goes. A programme never runs without its host.
A phone goes quietEach player in a tournament match has a clock that runs only on their turn, kept on the match's own row. Some of it comes back when the turn passes to the other player, and never for an action that leaves the turn where it was (a second hop, another box, a fleet shuffled again), so nobody can keep a round open by tapping. Where both players are at it at once (hiding the fleets in Sea Battle) nobody is on turn, so no clock runs and nobody is charged: the game times that stretch itself and ends it, and the first turn starts on the clocks as they stood. Run out on your turn and the match goes to the other player; a move that arrives after the clock ran out is not played. A game on its own total clock (Chess) is the one exception to who keeps the time: once both players have moved, the game's own clock times every move and ends the game when it runs out, through the same scheduled timer any timed game uses, so the server runs no player's clock beside it. Nobody's time runs before their own first move, so the server times that move instead: each player has one whole minute for their first move, and running out is a no-show that gives the match to the other player, the same way a clock running out does. The server keeps one backstop, both players' whole clocks and half a minute more. Should the game's own timer ever miss, the backstop tells the game its time is up, so the game still names who ran out; only a game that gives no answer is called on the score. A match found with nobody on turn and nothing timing it has nothing to wait for, and is called on its score at once.
Someone watchesAnyone in the room can open any live match by its id and is served exactly what a spectator is served today. They can back a player from the draw until 45 seconds after the match starts, or until it ends, whichever comes first. As a match is settled every pick on it is marked right or wrong, so a fan's phone reads its own points from its own few rows; at the end they are counted for the best fan.
A party moves onThe next game is stored with its start time (the "next up" moment), then started with everyone in the room seated, the host running its table. After a game that was played to its end, that start time leaves a few seconds first for every phone to finish showing how the game ended, so the countdown a phone then opens on is a whole one. When the game that is up changes during those seconds (a friend coming in puts it out), the one that takes its place starts no earlier than the start that was planned. Nobody skips a game. A game the platform has to stop because a player walked out of a table that could not lose one is kept as played but stopped short, with whoever stayed at it: no award reads a place off it, and it still counts as one of their games. A game that no longer fits the head-count is passed over when its turn comes, and one that will not start is passed over with the game's own reason. Every party game plays its built-in content and starts fresh.
The host takes a breakBetween two party games the host can pause the countdown, and resume it. The programme's row says it is held and has no start time while it is, so every phone shows the clock stopped, a phone that reloads included. The job that was booked for the countdown is cancelled with the pause; one that wakes anyway finds the hold and starts nothing. Resume books a new job for a whole countdown from that moment. A friend who comes in and changes which game is up during a break finds that game waiting on Resume too. Only a party between games has a countdown to hold: while a game is on, in a tournament, or once the night is over, a pause does nothing, and neither does a second one. A room is allowed ten pauses a minute.
It endsThe podium and best fan, or the awards, are stored on the programme and the room shows its result screen. A party whose last game has just ended opens on that game's own moment (below). A party where no game was played has no awards show: nothing is booked to move one. The host plays the whole thing again or goes back to the lobby (another party may be given a new line-up of games); a guest can always leave. A single match's rematch is refused here.
A party's awards are shownThe party also stores who was part of the night (name and colour): everyone who finished a game, a friend who went home before the end included, and everyone in the room as it ended. It stores where the end of the night is too: the moment after its last game (that game's winner, with the awards up next, and the stored moment every phone counts down to), then the curtain, the trophies, one award at a time, the photo. A night that ended because nothing left could be played has had its countdown and opens on the curtain. The show moves one step at a time, from the moment the caller names: the host's button, or a job the server books for when that moment has waited long enough. A step from a moment the show has already left does nothing, so a double tap or a late job never skips an award. Every phone reads the position through its own small question beside the party's, so a step sends three small values and the room is on the same award; a phone that reconnects lands on it.
The host says "start now"Between two party games the host can start the one that is up without waiting for its countdown, on a break or not. The call names the game, so a second tap, or one that lands after the game changed, does nothing. After the last game the same key moves the end of the night on one step, to the curtain.
๐ŸŽฒ The draw's seed stays on the server Every draw still to come is made from one seed: the next pairs, the free pass, the coin after a draw. A phone holding it could read the coin early. So the screens are sent the answers (rounds, table, podium, where each player stands) and never the seed. It is the same kind of seed a match has: 256 bits from the server's strong generator, so the round one a room is shown cannot be searched back to it. A programme that was started with the old, small number keeps drawing what that number draws.
๐Ÿƒ Each match is dealt from a seed of its own Every match a tournament or a party starts gets its own fresh seed, the same way a match started from the lobby does. None is worked out from the programme's seed or from another match, so nothing seen in one match says anything about another.

What's in the database

Every table is defined in convex/schema.ts (plus the standard sign-in tables that Convex Auth brings along; the account row carries one field of ours, the moment it was blocked, under "Protection from outside" below):

TableOne row perโ€ฆHolds
roomsroomThe 4-char code, the host, status (lobby / playing / ended), room size, title, the scene the room waits in (an id the host picked; none means the living room), and the host's picks: live in the lobby so guests can watch them, then what the match is played with (written by the server at kick-off, for the play-again sheet) until the room is back in the lobby
playersperson in a roomName, player color, host flag, the guest's remembered ID for reconnecting, and the game they would like the room to play next (their vote). The vote sits here, on the person's own row, so thirty guests voting never write one shared document, and it is spent when a game starts. Where someone stands in the lobby is not stored at all: it travels between the phones on the live channel
knocksperson waiting at the doorThe name they knocked with and when. Deliberately not a flag on a player row: a knocker must never be readable as a member by any screen that forgets to check
deniedKnockssession turned awayThe session the host said Keep out to, for that room only, so its next knock is refused. Session only, never the name. Drained with the room
presenceperson in a roomHeartbeat timestamps โ€” kept in its own table so beats don't spam everyone's roster subscription. Phones read it only while the room's seats are on their screen; the clean-up of silent rooms reads it always
gameStatesmatchThe seed (256 bits of chance written as 64 hex characters; a number in a match started before seeds were wide), a version counter (feeds the dice), who's seated (with each seat's name and the colour it played in, written as the match began, so a result still names and colours a guest who has left), the chosen settings, and the game's own state blob. A room's own finished match stays for its winner screen and is cleared away when the room starts something else. A programme's match also carries which fixture it is played for, in a tournament the two players' clocks, and once it has ended how it came out, marked unread until the programme has read it. A match with a deadline or a clock also remembers the scheduled wake-up that is waiting on it, so the move that makes it pointless can cancel it. That is never sent to a phone
programmestournament or partyWhich kind, whether it is running or over, what it was started with (for play again), the shared rules' own state, when what is waiting starts, whether the host is holding a party's countdown for a break and the job that is waiting for it, and at the end the podium or the awards (a party's with who was there to take them) and where the end of a party's night is. Written only by the programme's advance, the host's own start, pause, resume and end, and the awards show's one step
fixturesmatch of a programmeWho plays, whether it is waiting, live or done, when it starts, which match it is played on, and how it came out. The bracket is read from these, so no move ever wakes it
fanPicksfan and matchWho a watcher is backing. Written only by that watcher, so a rush of picks never touches the programme
generatedPacksroomThe host's in-progress content pack โ€” the topic they asked for and the items themselves. Deliberately not parked on the room row, which every member of the lobby is subscribed to: it would push a few kilobytes of questions at thirty people for something only the host ever opens
aiUsagegenerationOne line per attempt to write a pack, filed against the signed-in host: which provider answered, how big the request was, and whether it produced anything usable. Failures are recorded too โ€” a broken key that wrote nothing still cost money and still has to count
bookingssponsor bookingA business buying a place on the website: the signed-in account, a few words, an optional slot (the slot types are not decided yet, so nothing reads it), the price copied from the server's own list at the moment of the hold, where the booking stands, its Razorpay order, payment and refund, and when each step happened. Details under "Sponsor bookings and payments" below
waitlistemailSomeone who asked to hear the moment rooms open: their email, tidied (no spaces round it, all lower case) so one person is one row, when they joined, and where on the site (the home's hero). Written only by waitlist.join, which needs no sign-in; nothing reads it back to a phone, and an email already on it gets the same answer as a new one, so no one can ask who is on it
playStatsday, game and shardRunning counts of online rooms created, matches started, matches played to a result, and seats filled. The only record that survives the reaper, which deletes rooms and matches within the hour. Details under "Play counts" below
๐Ÿ’ณ Why usage is recorded before anything is charged Nothing bills anybody today. The row is written anyway, keyed to the host's account (never a room or a phone, which anyone can mint more of), so the day paid credits are switched on there is nothing to retrofit: one gate function starts reading a balance instead of counting attempts, and no other part of the codebase has to learn about money.

Play counts โ€” has anyone played?

Rooms and matches are deleted soon after a party ends, so they can only show who is playing right now. A small tally table keeps the history.

CountedWhen
Rooms createdA signed-in host opens an online room
Matches started, and seats in themA host starts a game or a rematch
Matches finishedA game reaches a result, including a walkover when a player leaves a duel. An aborted game is started but never finished
๐Ÿ“ต Not counted Same-device play. It lives in one phone's memory and never reaches the backend. Also not counted: people, only seats โ€” the same friend in five matches is five seats.

Where to look: Convex dashboard โ†’ the production deployment โ†’ Functions โ†’ stats:summary โ†’ Run. It takes an optional number of days (30 if left empty) and returns totals, one line per day and one line per game.

Each count is written in the same step as the thing it counts, so they can never disagree. A busy day spreads its writes over eight rows per game rather than one, so a rush of starts never queues behind a single counter. Nothing in the app reads these rows; only the dashboard does.

Why games add zero backend code

The backend is five generic pieces. None of them contains a single game rule:

rooms.ts โ€” the front desk

Create room, mint codes, knock, approve, deny, leave, seats. Pure lobby plumbing.

engine.ts โ€” the referee

Start / act / rematch / abort โ€” for any game. It looks the game up by ID and runs its rulebook. Doesn't know or care which game it is.

programmes.ts โ€” the organiser

Tournaments and parties for any game: start, pause and resume, play again, fan picks, and the bracket the screens read. Every decision is the shared rules' (@g4g/engine); this only feeds them and writes down what they say.

packs.ts โ€” the content desk

Host-supplied content for any game that accepts it: write a pack from a topic, type one by hand, reorder it, throw it away. It has no idea what a trivia question is.

games.ts โ€” the menu

The one game-aware file, and it is derived: it reads the app's own list of pure rulebooks, so a registered game lights up online without anyone editing a backend file.

One more thing is read the same way: the answer lists that only the server may hold (lib/decks.ts, next section). It is a lookup, not a function a phone can call.

Answer lists stay on the server

A quiz question is worth nothing once its answer sits inside the app, because anybody can read the app. A phone is sent the question so it can draw it, and a script looks the answer up. So a game whose content is a secret has two lists that share no item:

ListWho is dealt from itWhere it is
The room listEvery match in an online roomOn the server only. The app's build refuses to include it. Every room list wears a seal, one string that goes wherever the list goes, and the site build searches what it is about to upload for that seal and for each question's and card's own words.
The one-phone listA match on one shared phone, and on a hotspot host's phoneInside the app. On one shared phone that is safe: everybody is looking at one screen, so there is nothing to look up. On a hotspot it is not: each guest holds a phone of their own, and their copy of the app carries this list. That is open until the hotspot mode returns (the pause page).

The rulebook holds neither list. Whoever runs it hands it one as the match begins: the server hands the room list, a phone hands its own. The rulebook deals what the match needs and keeps those items in the match. A rulebook that was handed nothing refuses to start. It has no list of its own to fall back on, because the only other list there is ships inside the app.

ConcernHow it's handled
Every way a match startsThe lobby's Start, a rematch, a party's game and a tournament's match all begin a match in one place (insertMatch), and that place hands over the list. A test starts each of them for real and reads the stored match: items of the room list, none of the app's. A tournament is for games that seat exactly two, and no game with a list does, so today that start is refused, and the test passes on the refusal. The day one of them can be played in a tournament, the same test searches its matches.
A tampered host phoneThe list is not a setting, so there is no field a phone could fill in. It is never saved with the match and never sent to anyone.
Not repeating a questionUnchanged. A room remembers the ids it has played across rematches, and the rulebook skips them in whichever list it was handed.
A host's own questionsStill win. The room list is what a room plays when the host brought nothing.
An item never changes sidesA list only grows, on its own side. Moving an item from the room list into the app publishes an answer rooms are still being dealt, so the backend that has stopped dealing it deploys first. Moving one from the app into rooms hands rooms an item every installed app already holds.
What the site build can vouch forThe web export. The same search that looks for the room lists must also find every one-phone list there, so a search of the wrong place cannot pass. A phone build is not searched: there the build's refusal to include a server-only file is what stands.
CostNo extra calls and no extra reads: the list is part of the server's code, where the rulebooks already are. A match stores what it was dealt, not the list: its questions, its cards, a hand of pairs, a bag of words. About a kilobyte.
What it promises, and whenA phone never holds an answer before its moment. That is true of the code. It is true of a list's content only once the room list is made of items that were never in a build of the app. The four room lists today are items moved out of the app, so every older copy of the app, the site as it is served before this ships, and the repository's history still hold them with their answers. New items for the room lists are what make it true. It never makes a list unknowable to somebody who plays all the way through it. A longer room list answers that, and adding to it needs a backend deploy, never an app release.

Trivia Clash, Gibberish, Undercover and Quick Draw play this way. The why, and what was turned down, is in decisions (2026-10-08, "Answer lists leave the phone").

Content a host supplies

Some games play from content โ€” questions, prompts, word lists. Those games can let the host bring their own: type "90s cartoons" and have a set written for the room, or type the questions out by hand. This is a platform capability, not a feature of any one game.

A game opts in by declaring that it takes packs: how few items make a usable set, how many to ask for, what one item is CALLED so every line the host reads is in the game's own language, one sentence saying what a single item is, the shape of one item, and one function that judges whether an item is valid. It may also say whether its items have an easy and a hard at all (a quiz does, a word list does not), add its own instructions for the writer, offer its own ready-made sets as buttons the host can tap, and say that one of its items is a single typed line rather than a little form. It says whether the order of its items means anything (a quiz asks in order, a word grid scrambles them, so it skips the arrange step), whether one set can play match after match without repeating (a word list can, a quiz's questions are spent), and whether a writing service may make its items at all โ€” a game whose content a model cannot be trusted to write says no, and the server refuses a generation for it outright, not just the button. That declaration is the whole opt-in. The backend supplies the writing, the checking, and the storing; the game supplies no code for any of it, and the second game to want packs adds nothing to convex/ at all.

A set the game already ships is a pack like any other: tapping one of those buttons stores those items as the host's pack and never asks an outside service for anything. So there is one road into a match โ€” the words or questions in play always arrive the same way โ€” whether the host typed a topic, tapped one of ours, wrote their own, or asked for a random mix.

The same validity function is used in every place a pack is judged โ€” as it comes back from the writer, when the host reads it, and again inside the game's own rules at kick-off. So a pack can never be acceptable in one place and rejected in another, and a set that arrives mangled simply falls back to the game's built-in content rather than ruining the party.

ConcernHow it's handled
Who may askThe host, always, checked on the server against the room's own host. Guests never sign in and can never reach any of it.
Who writes itTwo outside services, one interface. The first is asked to answer in a fixed shape, so its output usually arrives already correct; the second is the fallback and only promises valid JSON, so its answer is put through exactly the same check. Both fail โ‡’ the game plays its built-in content.
The host's topicCapped in length and handed over as a clearly-fenced subject, never mixed into the instructions โ€” so "ignore the above andโ€ฆ" typed into the box reads as a topic about ignoring things, not as an order.
The brakesThree, asked in order in the one step that claims a generation, before a penny is spent. A switch on the deployment that stops the feature dead without an app release or a store review. The account's own number for the day (its plan's; sixty today, far above what a host makes). And one number a day for all accounts together, which bounds the day's bill. A request that is refused never reaches the writing service and is not written down as a use. A line is written for every attempt that is let through, against the host's account.
Where it landsIts own row, host-readable only. At kick-off the server merges it into the match's settings and it rides along with the match from then on, so a rematch replays the same set with a fresh shuffle for free.

Sponsor bookings and payments

The app is free for players. Money comes from businesses that book a place on the website and pay by Razorpay (rupees in India, dollars abroad). This is the payment layer only: it knows nothing about what a place is, and the slot work plugs into it later. Web only: no phone app sells anything, and nothing here runs on one phone or on a hotspot.

  held โ”€โ”€โ–บ paid โ”€โ”€โ–บ approved โ”€โ”€โ–บ live โ”€โ”€โ–บ ended
   โ”‚         โ”œโ”€โ”€โ”€โ”€โ–บ rejected โ”€โ”€โ–บ refunded
   โ”‚         โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ–บ refunded   (all of it given back by hand)
   โ””โ”€โ”€โ–บ expired โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ–บ refunded   (only a payment that came late)
StepWhoWhat happens
HoldThe signed-in sponsorThe page names a price by its id, never by its amount. The server copies the amount from its own list, holds the booking for fifteen minutes and opens a Razorpay order for exactly that amount. Ten an hour per account at most. Today's two prices are test prices, and a test price is refused unless the deployment's key is a Razorpay test key (rzp_test_): with live keys nothing sells until real slot prices exist
PayRazorpay's own checkoutThe card, bank or UPI screens are Razorpay's, inside its window. Our server never sees a card
PaidThe checkout's return, and Razorpay's webhookThe return carries a signature only Razorpay and we can make; even then the server asks Razorpay itself whether the money was taken. The webhook (/razorpay/webhook) is Razorpay telling us directly, signed with a second secret. Both land on one step, so the second finds the work done
Approve or rejectThe ownerA reject gives the money back by itself. No screen yet: run from the Convex dashboard
ExpiredThe hold's own timerBefore letting go, the hold asks Razorpay whether the order was paid; if Razorpay cannot say, it asks again a minute later, a few times. Money that arrives after the hold has gone is given back
๐Ÿ’ณ Razorpay is the truth about money A booking is marked paid only when Razorpay says the money was captured, and only for the exact amount and currency it was held at. Anything else that arrives (the wrong amount, a second payment, a payment after the hold) is given back, once: every refund is asked for under Razorpay's own idempotency key (the X-Refund-Idempotency header) named after its payment, so asking again never makes a second refund. A booking is refunded when Razorpay says the payment has given all of it back: its processed refunds, ours or made by hand in Razorpay's dashboard, add up to the amount (a failed refund counts for nothing). So money that went back in parts still finishes the booking, a paid booking given back in full by hand is refunded and can no longer be approved, and part of it alone is logged for the owner and changes nothing. An order reaches the page only once it is pinned to its booking. A captured payment on an order no booking holds is logged loudly. The keys and the webhook secret live on the deployment (RAZORPAY_KEY_ID, RAZORPAY_KEY_SECRET, RAZORPAY_WEBHOOK_SECRET); only the key ID ever reaches a browser.

Protection from outside โ€” limits, sizes and one switch

The backend sits on the open internet and is paid for by the call. Three things stand between it and someone who means it harm, and none of them is something a real evening meets.

WhatIn plain wordsWhere
How oftenWhat can be asked in a loop from outside a room, or by an account, has a number: question sets a model writes (per account and for everybody), emails given to the waitlist (for everybody), rooms opened, matches started, changes to a room's name, size and scene, new knocks at a door, and how often a knock or a player's "I am here" is written again. Over the number, the call is refused with its own short line, or succeeds and writes nothing. What a seated player can repeat (moves, lobby taps, fan picks) is not counted yet.convex/lib/limits.ts
How bigEverything a phone sends that is stored or handed to the room is weighed first: a host's settings, the lobby's mirror of them, a move, a host's own list, the id a device goes by, and an email given to the waitlist. A list is also cut down to the fields an item is made of.convex/lib/sizes.ts
One accountAn account that is being used to attack the service can be blocked from the dashboard. Its open room closes and it hosts nothing more until the block is taken off.users.block, users.unblock
๐Ÿงฎ Who is counted, and why never "per visitor" A Convex function is not told the network address a call came from, so no limit here can be per visitor. A guest's id is made up by their phone, so a script makes up another. What the server does know is the signed-in account (only a host has one), the room, and everybody together, and every limit counts one of those. A refused call is still a call we pay for; a limit stops what the call would have done. The brake on the bill itself is set in the Convex dashboard: releasing.
๐ŸŽŸ๏ธ Safety limits and plan limits are two things A safety limit is the same for everyone and sits far above a real evening. A plan limit is what a free host and a paying host may do. The plan numbers are not decided yet: one small table holds a row per plan, every plan is allowed the safety number today, and one function says which plan an account is on (everyone is free; there is no payment system). When the numbers are chosen they go in that table, and their refusal is a different line on the screen from "slow down".

The counting is done by Convex's own rate limiter component, inside the same step as the thing being limited: if that step fails, its turn is given back, and two calls at once cannot both take the last one. Every number is checked against the games themselves, so a game that grows past one fails a test and not a player. The numbers and what each line says: decisions, 2026-10-08.

Housekeeping โ€” every table has a reaper

Nothing here is allowed to grow forever, and a cleanup job that bites off more than one transaction can hold is worse than none: it fails, retries identically, and never advances again. So each job counts its deletions, stops at a budget, and books itself in again to carry on.

JobClears
Silent rooms (every 15 minutes)Rooms nobody has been seen in for thirty minutes โ€” measured on the roster's own heartbeats, never on the room's age, so a party that runs past midnight keeps its room and a winner screen people still sit on is left alone โ€” and everything hanging off them: players, matches, heartbeats, people still knocking, the sessions turned away at the door, the host's content pack, and the room's programmes with their fixtures and fan picks. This is what actually frees the room code for reuse.
A programme's leftovers (when it ends, and when the next one starts)A finished tournament's fan picks go as soon as they are counted. When a room starts another programme, the one before it is cleared away: its picks, its fixtures with the matches played on them, then the programme itself, last. Whatever is still there when the room goes quiet leaves with the room.
Old usage lines (daily)Generation records past ninety days. This one needs its own job because it hangs off a person, not a room, and a person outlives every room they ever open โ€” nothing else would ever remove it. What an account may make is counted by the limiter, never from these lines, so ageing one out changes nothing a host can do.
The limiter's counts (daily, at midnight UTC)One small count per account or room, in the limiter's own table. Each goes a week after it was first written, in use or not, and starts again full. The job runs at midnight UTC because that is when the daily counts start again anyway, so clearing one hands nothing back.
A room's finished match (at the room's next start)The match the room played last, once the room starts anything else: a match, a rematch, a tournament or a party. Nobody reads it after that. A match played for a programme is left to the programme's own clearing.
Sponsor bookings (hourly)Holds that ran out without any money, six months after they ran out: a payment can still land on an expired hold's order, and only the booking can say it is not ours and give it back. A booking that took money is a record and stays. The same job hands any hold whose own timer never ran back to its expiry, a batch at a time.
Old play counts (daily)Tally rows older than 400 days โ€” long enough to compare this year with last.
The waitlist (none, on purpose)Nothing. The list is kept until launch: it is the people to tell when rooms open. What bounds it is the breaker for everybody, a hundred thousand new emails a day at most.
๐Ÿ” The same rulebook as everywhere else The rules the referee runs come from @g4g/engine and each game's game.ts โ€” the identical files the phone runs in same-device mode and the host runs in LAN mode. Randomness is seeded (derived from the match's seed + move counter) and time is injected, so the server never needs to trust a phone's clock or dice โ€” and results are reproducible.
๐Ÿ”’ The seed nobody can work back to A player is shown some of what a match draws: their own fleet, every card turned over. So the seed must be something that cannot be found from those draws. It is 256 bits from the server's own strong generator, minted in the same step that starts the match, and it never leaves the server: no answer to any question a phone can ask carries it. The dice are a stream (ChaCha20) that gives nothing away about its seed or its next number however much of it has been shown. A match that was started with the old, small number seed keeps drawing exactly what that number draws, so it plays on unchanged.

The deploy rule โ€” the one way to break production

โš ๏ธ Two tracks, one order The website ships with bun run deploy. The backend ships only when someone runs bunx convex deploy -y โ€” the site deploy never touches it. So if app code that calls a new backend function goes live first, the live site calls a function that doesn't exist yet. Always deploy the backend first, then the site. Full checklist: docs/releasing.html.
EnvironmentUsed by
Developmentbunx convex dev on this machine, the dev app and local exports
ProductionThe live site and the shipped apps. The deployment names live in docs/releasing.html, and the site build refuses a bundle pointing at the dev one

Secrets and switches live on the Convex deployment itself โ€” set with bunx convex env, never committed to the repo: the Google and Apple sign-in and token keys (the Apple secret expires every six months โ€” RENEWALS.md), a key for each of the two content-writing services (GEMINI_API_KEY, DEEPSEEK_API_KEY), and the kill switch that turns host-supplied content off (AI_PACKS_ENABLED=0). The three Razorpay values under "Sponsor bookings and payments" above are set there too, test keys on dev and live keys only ever on production. Two more belong to the live channel: LIVE_SECRET, which signs the relay passes, and LIVE_HTTP_URL, where a removed player's kick is sent (unset on dev, which cannot reach a relay on a laptop). A deployment with no keys set simply offers no writing service and every game falls back to its own content โ€” which is also why these are environment values and not code: turning the feature off is seconds, with no release and no store review.

Decision log

Last updated: 2026-10-17 ยท Maintained under the CLAUDE.md rule "Architecture docs โ€” keep them in sync". If the code changed and this page didn't, that's a bug โ€” fix the page.