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.
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.
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.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.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 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.
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.
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.
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.
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.
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.
| Moment | What the server does |
|---|---|
| The host starts a tournament | Checks 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 out | A 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 ends | Its 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 removed | If 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 leaves | The 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 quiet | Each 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 watches | Anyone 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 on | The 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 break | Between 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 ends | The 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 shown | The 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. |
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):
| Table | One row perโฆ | Holds |
|---|---|---|
rooms | room | The 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 |
players | person in a room | Name, 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 |
knocks | person waiting at the door | The 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 |
deniedKnocks | session turned away | The 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 |
presence | person in a room | Heartbeat 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 |
gameStates | match | The 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 |
programmes | tournament or party | Which 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 |
fixtures | match of a programme | Who 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 |
fanPicks | fan and match | Who a watcher is backing. Written only by that watcher, so a rush of picks never touches the programme |
generatedPacks | room | The 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 |
aiUsage | generation | One 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 |
bookings | sponsor booking | A 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 |
waitlist | Someone 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 | |
playStats | day, game and shard | Running 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 |
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.
| Counted | When |
|---|---|
| Rooms created | A signed-in host opens an online room |
| Matches started, and seats in them | A host starts a game or a rematch |
| Matches finished | A game reaches a result, including a walkover when a player leaves a duel. An aborted game is started but never finished |
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.
The backend is five generic pieces. None of them contains a single game rule:
Create room, mint codes, knock, approve, deny, leave, seats. Pure lobby plumbing.
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.
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.
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.
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.
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:
| List | Who is dealt from it | Where it is |
|---|---|---|
| The room list | Every match in an online room | On 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 list | A match on one shared phone, and on a hotspot host's phone | Inside 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.
| Concern | How it's handled |
|---|---|
| Every way a match starts | The 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 phone | The 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 question | Unchanged. 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 questions | Still win. The room list is what a room plays when the host brought nothing. |
| An item never changes sides | A 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 for | The 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. |
| Cost | No 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 when | A 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").
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.
| Concern | How it's handled |
|---|---|
| Who may ask | The 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 it | Two 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 topic | Capped 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 brakes | Three, 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 lands | Its 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. |
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)
| Step | Who | What happens |
|---|---|---|
| Hold | The signed-in sponsor | The 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 |
| Pay | Razorpay's own checkout | The card, bank or UPI screens are Razorpay's, inside its window. Our server never sees a card |
| Paid | The checkout's return, and Razorpay's webhook | The 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 reject | The owner | A reject gives the money back by itself. No screen yet: run from the Convex dashboard |
| Expired | The hold's own timer | Before 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 |
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.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.
| What | In plain words | Where |
|---|---|---|
| How often | What 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 big | Everything 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 account | An 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 |
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.
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.
| Job | Clears |
|---|---|
| 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. |
@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.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.| Environment | Used by |
|---|---|
| Development | bunx convex dev on this machine, the dev app and local exports |
| Production | The 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.
waitlist table and one public mutation, waitlist.join, with no sign-in: the home's
hero asks for an email while rooms are not open. A known email is answered like a new one and writes nothing,
every call is counted for everybody (a hundred thousand a day), and the table has no reaper until launch. The
backend must be deployed before the site that shows the form. Why: decisions,
2026-10-17.bookings table, the payment actions, and Razorpay's webhook at /razorpay/webhook. The
price is the server's, the booking follows Razorpay's word, a reject refunds itself, and a hold lets go after
fifteen minutes. Test mode only so far: a test price is refused on live keys. Refunds are once only by
Razorpay's idempotency key, and a booking is refunded when its payment has given all of it back, in one
refund or in parts. The booking's code is one small file per job, in convex/booking/. Why: decisions,
2026-10-15.players.sit is deleted and the
roster no longer carries a place. The field leaves in two deploys, because a field cannot be dropped while a
row still holds it: first the function and every reader go and a small job clears the field off every row,
then the field leaves the schema and the job is deleted. Why:
decisions, 2026-10-13.rooms.scene): none means the living
room, so older rooms and older phones need nothing. It is set with the name and the size
(rooms.updateDetails: the host, in the lobby, one of the ids the server knows) and read with the
room. That call now writes only what changed, and a room takes thirty changes a minute. Who is standing where
in the scene is still stored nowhere. Deploy the backend before the site. Why:
decisions, 2026-10-12.players.sit and the row's place stay for
phones on an older build. The host's mirrored picks also say which kind of plan is in hand; the server
passes that through as it does the rest. Why: decisions, 2026-10-12.players.sit writes its name to their own row.
Only a seated player can call it, only for themself, only while the room is in the lobby. The roster carries it
to every phone. Why: decisions, 2026-10-02.decisions.html.karims-taste.html.decisions.html.decisions.html.decisions.html.decisions.html.packs.ts) alongside the front desk, the referee and the menu. Two new tables
came with it: the room's pack (its own row, host-readable only, because the whole lobby
subscribes to the room document) and one usage line per generation, filed against the host's
account so paid credits can later be switched on at a single gate. Brakes from day one: three
per room, twenty per host per day, and an environment kill switch. Also on this date: a rematch
now carries the game's own memory forward, so a room never replays a question
it has already seen. See decisions.html.decisions.html.decisions.html.decisions.html.onPlayerRemoved instead of sneaking it in
as a special move. Every game must say what losing a seat means โ or explicitly
say "not supported" โ so a player quitting can never crash a game that simply
forgot to plan for it.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.