A decision belongs here when someone would reasonably ask "why is it like that?" — a real fork in the road, a rejected alternative, a constraint we chose to live with. Not every change: adding a game is not a decision, choosing that games can't add Convex functions was.
Newest first.
Where things live. principles.html holds
the core principles that don't change — the yardstick these decisions are measured against.
learnings.html is what bugs taught us. The
docs/architecture/*.html pages say what the app does today, each with
a short plain-language "Decision log" for the non-expert reader. This file carries the
reasoning — the why, the alternatives, and what would make us change our minds. Architecture
page = what changed. This file = why. Don't paste one into the other; link
instead.
Each entry: Decision · why · what we rejected · what would reverse it.
Decision: The mascot's little voice is four recordings, named for what is heard: yoo-hoo, woohoo, ooh and hmm. A cheer, a winner's confetti and a hello say "yoo-hoo", and so does a tapped mascot whenever its move has no sound of its own. A laugh says "woohoo", a surprise "ooh", a think "hmm". The happy "mm-hmm" recording is deleted.
Why: Karim is choosing Yuhu as the name, and "yoo-hoo" is that name said aloud: "the mascot's usual word is Yoo-hoo, and that is what the game name is also." Before this, two taps in three on the home page played the hum, the hello was heard only as a friend walked into a room, and a win had no voice at all (the winner's mascot enters in confetti, which was silent). He also heard the hum as "a sad, annoyed type of thing", not happy.
Rejected: adding a hello to the tap list (one in seven taps would say it, the hum still the most heard). Keeping the hum for a sad or annoyed moment: no mascot move is sad, so the file would be weight nobody hears.
What would reverse it: a name other than Yuhu, or players finding one word over and over tiring in a long game. Then the laugh's "woohoo" takes back the cheer, not the hum.
Decision: (1) The seekers get ONE clock for the whole seeking (the host picks 1, 2 or 3 minutes) and a number of tries between them, about a third of the open spots (4, 7 or 11). The round ends when the tries are used, the clock runs out or everyone is found. Each look gives its time on screen back to the clock. (2) The host picks the counting time (20, 30 or 45 seconds), the seeking time and the rounds (3, 5 or 8). Short, Normal and Long are gone. One shared phone is asked the rounds only, because it has no clocks. (3) Every section has 11 spots, so a place always has two more spots than seats: one section up to 9 players, two up to 20, three up to 30 (33 spots). (4) Every phone has a coloured band saying its part: green "Hide!" and "Hidden. Shh!", coral "Count!" and "Your turn to seek", purple "Shh! Stay hidden", "Found!", and yellow "Round over". The seeker's own phone is a full-screen count with their mascot's eyes shut. "Ready or not, here I come!" shows on every phone, and the lights go out over the place until the round ends.
Why: Karim played it with friends. "There is no visual distinction between the counting mode and seeking mode... it should be very clear that someone is counting, someone is seeking, and someone is hiding, even to the kids or to the elders." The second countdown people saw was each look's own 15-second clock, and nobody could tell what it was for.
Rejected: one try for each hider, Karim's first idea. At five players it is fair. In a full room it is 29 tries in 31 spots, so nearly everyone is found. Tries as a share of the spots keep a hider's chance the same at every size. Showing the place on the seeker's phone while the others hide. It was there before, and the full-screen count also keeps the seeker from watching the spots while friends choose.
What would reverse it: a played evening where the seekers run out of tries with time to spare every round, or never use them in the time. Then the share of spots moves, not the shape.
Decision: a seeker's walk takes as long as its way is, at about three of their own heights
a second, with a soft start and stop, and the spot opens when they arrive. The shortest walk is 0.7 seconds
and the longest 2.1. A walk longer than that speeds up to fit. The time and the door are worked out from the
one walking pace, so the seeker can never arrive after the spot has opened. The look's time on screen
(SHOW_MS, 3.5 seconds) is unchanged, so the server is unchanged too.
Why: Karim played it with friends and the seeker "flying from one place to another". Every walk took 1.5 seconds whatever its length, on a curve that raced through its middle, so a long walk crossed the place at about ten heights a second.
Rejected: a longer look on screen (5.2 seconds) so even the far end of a place is a stroll. The game's own ceiling (a normal game with every clock run out stays under 21 minutes) already sits at 3.6 seconds a look: twelve players with three seekers broke it. Going past it means fewer rounds or a higher ceiling, which is Karim's call, not a side effect of an animation.
What would reverse it: the longest walks still reading as a dash on a real phone. Then the look gets longer and the round count or the ceiling moves with it, in the rules.
Also: the bed was the one spot whose hider stood whole beside the furniture, showing nearly all of them. It now peeks over the bed's foot like every other spot, and the "stand behind it" drawing is deleted with it: nothing else used it.
Decision: the Party Pass code is reverted whole (pull requests 76 to 79): the pricing and refund
pages, Paddle's checkout and webhook, the plusPasses table, and the perks a pass unlocked. Every host
gets every scene, AI questions and rooms past 8 again, and the terms and privacy pages no longer name Paddle. The
two entries below stay as the record of what was built and why.
Why: Karim, 27 October: the app stays free for its first one or two months to get traffic, and longer if sponsors pay for it. If payments come back, the one thing the pass would gate is a room of more than 8 players; scenes and AI questions stay free for everyone.
Rejected: hiding it behind one switch, the way the offline mode is kept (cheaper to bring back, but Karim chose to carry no payment code while none is planned). Rebuilding the pass to gate only room size now: nothing would be sold for months.
What would reverse it: the free launch ending without sponsors. Rebuild from the two entries below and the reverted pull requests, gating only rooms past 8. The Paddle sandbox catalog still exists.
Decision: the server refuses a host without a pass a room past 8 players, the pool, backyard
and dance floor, and questions a model writes, each with its own reason (PLUS_ROOM_SIZE,
PLUS_SCENE, PLUS_AI_PACKS). It asks when the host picks: opening a room, growing it,
moving it, asking for a set. The host's forms show the same choices as Party Pass options: the size stepper goes
past 8 and says "You need the Party Pass to host bigger rooms", a party scene wears a Party Pass tag and says the
same when tapped, and the topic box says it in place of the box. On the web the line links to the pricing page,
opened beside the screen; phones never point at buying. A room set up while a pass ran keeps its size and scene
when the pass ends, and its host may only go down from there. The free numbers live in one file,
convex/lib/planPerks.ts, read by the server and the app alike.
Why: Karim, 20 October: the host should not be able to make a room past 8, and a tap on + after 8 should say they need the Party Pass. A friend who knocks on a free room that is full is told it is full, as today: "nothing needs to be changed there." Checking when the host picks, rather than on every join or every read, keeps the pass out of every phone's path, and a room lasts one evening (it closes after half an hour empty), so a room that outlives its pass by an hour costs nothing.
Rejected: letting the knock through to the host with a Party Pass line (a selling
moment, but a new door state, and Karim said the door stays). Shrinking a room or moving it back to the living
room the moment a pass ends: it would throw friends out mid-evening. Hiding the Party Pass choices from a
free host: nobody would learn they exist. A plan limit of zero sets a day for free hosts
(PLAN_ALLOWS): a count is the wrong shape for "not included", and its refusal would read "that is
plenty for today".
What would reverse it: rooms that stay (the permanent rooms plan): then a room outliving its pass by weeks is real, and the check moves to when the room is opened each time.
Decision: every game stays free. A host may buy Plus, sold to players as the Party Pass (the name is one line of copy, site.plus.name; the code keeps calling it Plus): the pool, garden and
dance floor scenes, more than 8 players in a room, and the AI modes (Trivia Clash's questions written by AI;
its built-in questions stay free). Free is the living room, up to 8 players, every game. Only the host pays and
the whole room gets it. Plus is a week ($5.99, ₹149, paid once), a month ($9.99, ₹199) or a year ($39.99, ₹999); euro countries pay €5.99, €9.99 and €39.99 and pound countries £4.99, £8.99 and £34.99; Paddle converts the dollar prices for every other country; tax is by location (included in India, Europe and the UK, added at checkout in the US, as each place expects); and the page shows each visitor Paddle's own price for where they are, the
month and year paid once or renewing at the same price. The page opens on the year, renewing: the year is
where the money is. Refunds: in full within 14 days, if no Plus feature was used, if Plus did not work, or for
a renewal not used since; the free plan is the trial. Paddle sells it, as merchant of record,
on the website only. Sponsors are invoiced with a GST invoice and pay by bank transfer, by hand, until the
business is bigger. How Plus works: backend page.
Why: decided by Karim on 19 October. Razorpay's live approval wants a gaming addendum and an advocate's opinion, which cost time and money before a single sale. Paddle asks for neither: its checks are the site's terms, privacy, refund and pricing pages. It takes payments from abroad, handles every country's tax, and takes UPI in India, which most Indian buyers use. A week never renews because a party is one evening and a weekly charge somebody forgot is a refund and a chargeback waiting to happen. Indian prices start at ₹149, not ₹99: after 18% GST and Paddle's 5% + $0.50 a ₹99 week leaves about ₹35, and ₹149 about ₹75; next to it the ₹199 month (about ₹115) is the obvious pick. Plus is decided by the server from what Paddle reported, by the server's clock, so a phone can never make itself Plus.
Rejected: sponsors through Paddle: Paddle's list of what it will not sell names "advertising, and sponsorship". Razorpay now, for its paperwork; the sponsor payment layer stays built (2026-10-15) for when it is worth it. Selling Plus inside the phone apps with Paddle: outside the US, Apple and Google require their own checkout for anything unlocked in an app, so the apps show Plus and never sell it. A weekly subscription, for the reason above. Calling Paddle's API from our server: the page opens the checkout itself, and the webhook brings everything back, so the deployment holds no Paddle API key at all.
What would reverse it: Paddle refusing the site at verification; enough sponsors that invoicing by hand takes real time (then Razorpay, with the advocate's letter); the phone apps wanting to sell Plus themselves (then the stores' checkout, writing the same Plus record).
Decision: "How are we playing?" is one card cut on a slant: everyone grabs a phone on the pink side, one goes round the table on the yellow, a tilted OR on the seam, and a slim "Got a code?" strip under the card. On a phone the halves stack, the yellow half's top edge leans, the OR sits on that line, and the two buttons are snug to their words so the slant can lean further. The scenes and their motion are unchanged.
Why: Karim, on the comic strip as built: a lot of empty space above the scenes and "no creativity here", only the animations were liked. Of three frames drawn around the same scenes he picked this one, "perfect for web view", and asked for the phone's smaller buttons and steeper slant himself.
What we rejected: the comic strip of three equal panels (entry below); each crew on its own round rug with no boxes; each crew on a candy block that is itself the button.
What would reverse it: players missing the code strip under the card, since joining is no longer an equal third of the section.
Decision: the App Home keeps its shipped hero exactly (the crew on the couch passing the
phone, the mascots jumping) and the sections under it are new, each one drawn idea: the waitlist sits in the
hero itself, under the words on a big screen and under the couch on a phone (entry
below); the three ways in are one comic strip, with "Your turn!" said by whoever throws the phone; the room
scenes are postcards on a line, and the one being looked at plays live, its three friends riding, swinging and
dancing in the real scene; a party and a tournament are posters pinned up; the game stickers' shelf gets "Pick for
us". The code is in apps/app/src/home/crew/; a postcard's still is the scene picker's own picture
(ui/scene/SceneThumb), and the played one is laid over it from the same box, window and cast.
Why: Karim, 17 October, after three concepts that each replaced the hero: "This lacks any creativity and taste. Keep the main hero section of the current one as it is." The hero already met his bar, so the new work goes around it, and he chose these sections in an artifact he could open (the Crew Home Redesign), built from the product's real drawings, before any of it became code. The home has to show what the app now does (parties, tournaments, room scenes) and collect the waitlist while rooms are not open.
What we rejected: the three in-app concepts, each with a new top (all discarded, branch and all); the two other layouts the artifact drew for the ways in (each way said in a speech bubble over the friends, and one stage with a two-way switch and the code on a sticky note); a section of plain cards in a row, which is not a design on a page whose hero is a drawn scene; a still picture for the postcards.
What would reverse it: rooms opening takes the waitlist out of the hero (its folder and one line). Players not finding a way in from the comic strip, or a postcard costing a phone too much while it plays (it then stays a still), would change those sections. A new hero would need Karim to find one he likes better than the couch.
Decision: while rooms are not open, the home's hero asks for an email ("Opening soon. Get in
first."), on the website only. One public mutation, waitlist.join, takes it with no sign-in and
keeps it in a new table, waitlist: the email tidied (no spaces round it, all lower case, so one
person is one row), when, and where on the site. An email already on the list gets the same "ok" as a new one
and writes nothing, and nothing reads the list back to a phone. The email is weighed with the other things a
phone sends (lib/sizes.ts: 254 characters at most, and the shape of an email, the same check the
form makes before it sends). Every call takes one from a safety limit counted for everybody
(lib/limits.ts waitlistEveryone): a hundred thousand a UTC day, counted across
thirty-two rows. The table has no reaper. How it works: backend
page.
Why: Karim, 17 October: the waitlist at the foot of the page is seen by nobody, so it moves to the top, under the couch. It is the one door on the backend that writes for anyone without an account, and Convex is never told who is calling, so the only count it can keep is everybody's. A hundred thousand a day is far above a night of tens of thousands of visitors (what the site is built for) even if every one of them joined, and it bounds what a script can write in a day to that many short rows. Thirty-two rows mean a rush of thousands a minute is about one write a second on each, so a promo spike never queues on one row. A call is counted before the list is read: if only new emails were counted, a used-up day would answer a known email yes and a new one no, and tell anyone who is on the list. No reaper, on purpose: the list is the thing being kept, until launch, and the daily breaker bounds how fast it can grow.
What we rejected: a limit per minute as well as per day, which protects nothing the daily one does not (a script can use up either) and is the one a real rush would meet first; counting only new emails (it gives the list away, above); answering a known email "you're already on the list" (the same leak, said out loud); a reaper on the list (it would delete the people we promised to email).
What would reverse it: rooms opening: the form comes out of the hero (delete
apps/app/src/home/waitlist/ and the line in CrewPage that hands it to the hero), and
what happens to the list once the "we're open" email has gone is Karim's call. A script really filling the day
would bring a check that knows a visitor (a human check on the form, verified by the backend). Real joins
coming near the number would raise it.
Decision: while a phone is in an online room (the lobby, a game, a party, the result),
while a one-phone match or party is on, and while the hotspot host hosts, going back does nothing: the iPhone's
swipe does not start and Android's back button and gesture are refused. The way out is the room's own Leave,
End game or Stop hosting, which still work. One hook does it for every shell (modes/useStayInRoom):
the screen's swipe is switched off, and a removal that is a going back is refused while any other way out
carries on. A browser's Back is left alone.
Why: Karim, 17 October, after a night playing with Aafreen on two iPhones: exploring the room, dragging his mascot, and drawing on Quick Draw's canvas all took him home. On iOS 26 the swipe back starts anywhere on the screen, not only at its edge, so any sideways drag in a room is a way out. "Once a room is created, we should not go back to the home page."
What we rejected: trapping the browser's Back (pushing the page back onto the history each time it is left). A browser refuses to let a page cancel its Back button on purpose, so no site can keep anyone in, and the router reads the history itself, so a trap fights it. Going back on the web loses nothing: Home offers the room again and Forward returns to it. Also rejected: switching the swipe off on the room's whole route, which would also hold the knock screen and "room not found", where going back is right.
What it changed elsewhere: the round sheet of the turn games (ui/RoundDialog) was
locked for everyone, and going back was a guest's only way past it when the host walked off. It is now locked
only for whoever runs the table; anyone waiting on them may put it away until the next round's, and the
header's Leave is there. Found by the review before merge.
What would reverse it: players asking for a way to glance at Home mid-game without leaving their seat (then Home would be a button in the menu, never the swipe); browsers allowing a page to ask before a Back, the way they ask before closing a tab.
Decision: players pay nothing. Money comes from businesses that book a place on the
website themselves: pick a place and dates, sign in, give their company details (a GSTIN if Indian), upload the
ad and pay. Karim approves each one with a tap, and a rejection refunds by itself. They pay by
Razorpay: rupees in India, dollars abroad. The first piece built is the payment layer alone,
which knows nothing about what a place is (slot types are not decided): a booking held for fifteen minutes at a
price the server sets, a Razorpay order for exactly that, the checkout's signed return checked and then
confirmed with Razorpay itself, Razorpay's webhook as the second voice, a refund on reject, and every refund
asked for once under Razorpay's own idempotency key for the payment (X-Refund-Idempotency). A
test price sells only on a Razorpay test key. How it works: backend
page.
Why: decided by Karim on 14 and 15 October. A free app keeps every friend at the table: no host has to pay before a party, and the phone apps never need the stores' own checkout. A sponsor is a business paying a business, which is the sale Razorpay is built for: Karim is the seller with his own GST registration, money settles in rupees to his bank, and Indian buyers can pay by UPI and net banking as well as card. Razorpay's test mode works today, so the payment layer could be built and proven before the account is approved (domestic and international were applied for on 14 October).
Rejected: Dodo Payments, which turned online games away on 14 October; and Paddle, chosen the same day for a host's paid plan (demo booked). Paddle is a merchant of record: it sells a product to consumers on the seller's behalf, which fits a subscription for players, not a business buying ad space, and its approval waits on a pricing page, a refund page and a price for a plan that no longer exists. With Razorpay the invoice is Karim's own, with the buyer's GSTIN on it, which an Indian business needs to claim its tax back. Also rejected: the phone sends the amount (a price is the server's, named by id); trusting the checkout's return alone (it is signed, but the money is Razorpay's word, so the server asks); the webhook alone (it cannot reach a laptop, and a sponsor should see "paid" at once); keeping a hold on with no limit (a place someone abandoned would never come free).
What would reverse it: sponsors abroad who cannot pay by Razorpay's international cards, or the tax paperwork of selling abroad growing past what Karim can do by hand: then a merchant of record takes the foreign sales and Razorpay keeps India. A paid plan for hosts coming back would bring Paddle or Dodo back for that plan, beside this, never instead of it.
Decision: the header's sun or moon is a round switch like sound: one tap flips light and dark. The room's sign button, the phone lobby menu's row and Profile's switch do the same. The device always remembers "light" or "dark". On the first launch (nothing remembered) it takes the system's look at that moment and remembers it; an old build's "match device" (nothing stored, or the word "system") is settled the same way, once. After that the system's setting never changes the app. On the web the head's boot script does this before the first paint, so a dark-system first visit is dark from the first frame and a player who flipped is never shown the other look.
Why: Karim, 2026-10-14: "I would like to have it as a toggle only between light mode and dark mode, like how we toggle the sound on and off. Match Device: we can remove it." The menu was a tap too many for a thing people flip, which is exactly what the 2026-10-03 entry below named as its reversal.
Rejected: following the system live (his words: "Every time the user changes the device settings, we will not change it here"); keeping Match device on Profile (he removed it, and a look that changes by itself after a tap would read as a fault); storing nothing until the first tap (the look would then still follow the system until someone tapped).
What would reverse it: players asking for the app to turn dark with their phone at sunset; then "match device" comes back, on Profile only, and the header stays a one-tap flip.
Decision: the online room's games panel (Game, Tournament, Party) is a side panel a third
wide only on a laptop or desktop. Phones AND tablets get the bottom sheet a phone has: the scene fills the
screen, the bar at its foot opens the sheet, and the same bar is pinned to the sheet's foot. On a screen wider
than a phone the bar and the sheet stand in the middle, no wider than 720. "Desktop" is decided in one place,
ui/useIsDesktop: in the app never (a tablet is a tablet, however wide); on the web a window at least
1024 wide AND a mouse or trackpad (pointer: fine), read after hydration, so an iPad's browser gets
the sheet. The room keeps the big screen's header on a tablet; only the panel follows the new question.
Modal gained one option, sheet: a bottom sheet on every screen size.
Why: Karim: "The panel for showing the games, tournaments, and party is coming for the tabs also. I didn't like it. I would like to have it as a bottom sheet only, even for the tablets. Show the side panel only for bigger screens like laptops or desktops." On a tablet the side panel took a third of a screen that is held in the hand, and squeezed the scene that is the point of the room.
Rejected: a third layout tier for tablets across the app (the two-layouts rule stays; this is an exception for one panel); deciding by width alone (an iPad's browser is as wide as a laptop's); the tablet's centred dialog (Karim asked for the sheet); a full-width sheet on a tablet (a bar 1,000px wide is stretching).
What would reverse it: a big tablet in landscape where the scene has room to spare and the sheet feels like a detour, or a desktop where people would rather have the scene whole.
The same day, the party's games nobody here can play yet: under tonight's line-up, the games
this many are too few for were one long sentence ("Tic-Tac-Toe, Connect Four … need more of you"). Karim:
"It just shows the names of the games in text format. It is not looking good." They are now the room's own "Not
enough of you yet" group: each game's still, its name and what it needs, dimmed, in the room's grid
(ui/party/PartyTooFew over the room's Stills), in the party's own sheet on both shells
and on the room's Party tab. None can go in, so a tap opens its How to play. The empty "More games" title is not
drawn when there are none. Rejected: a mascot and an empty-state line in their place (the games are worth seeing:
they say what more friends unlock); the same tick rows as the games that can go in (they would read as
pressable). The games for smaller groups ("… are for smaller groups") are still named in one line: his
report was the empty sheet, and the same grid is the answer if that line reads as badly.
Decision: the pool loses its flip-flops, beach bag and rubber duck; its drink on a little
table stands at the foot of a lounger, a step back from it. Its slide stays as it was: a redrawn water slide
(an open chute seen from above, with rails, a ladder and a lip at the water) was tried the same day and put
back on his call, "I didn't like the slide redesign for the pool. It is looking odd. Let's revert it." The
garden loses its gnome,
wheelbarrow, watering can and the robin on the fence. After dark an owl sits on a branch of the apple tree
(bare by day); a tap makes it blink, fluff up, turn its head and hoot, on the phone that tapped it only. The
hoot is two soft tones made like every other game sound (hoot in
ui/audio), so no recording was needed. The living room's cat is removed: moved from the bean bag
to the windowsill, it looked to him "like it's floating in the air. Let's remove the cat and stop trying to fix
it." Its drawing, its tap and the sound it had go with it. In a box where the gate's rose arch stands
right of the tree (a phone), the owl's branch grows on the left instead, under the cable; no apple grows where
the owl sits.
Why: Karim, playing the rooms: props added "just for the sake of adding items", the drink "at the wrong place", a slide that "looks like a hot dog", a cat that "is not looking good on the beanbag", a day bird that "was looking very odd", and a creature that is kept should have "some kind of petting option" with a sound (Karim's taste). The cat on the bean bag could be petted by whoever sat in that bag; that petting was the only use of two things in the scene kit (a place whose own tap was a use of a toy, and the list of taken places handed to drawings), and both are removed with it rather than kept for nobody.
Rejected: the cat on the rug by the sofa: between the sofa and the bean bags there is about 20 points on a small phone, less than the cat; the cat on the windowsill: tried, and it looked like it floated. The redrawn slide: tried, and he found it odd. The drink level with the loungers: on a phone the loungers, the parasol and the slide fill the back deck edge to edge. The owl on the left of the tree: the zip line's cable runs through it there. Keeping the robin by day: he found it odd.
What would reverse it: a prop he asks for comes back where it belongs. A slide he names as wrong again gets options to pick from before anything is redrawn.
Decision: a game's how-to card (the rules sheet, and a game's page on a phone) has one small switch beside its "How to play" heading: Read rules · Watch video. Read shows the mode chips and the numbered steps, as before. Watch shows the same chips and then the video IN the card, in its 4 by 5 frame: its title picture with one play button low in the frame, and a tap plays it right there with sound and the platform's controls. It opens on Watch whenever the picked game and mode have a video; with none, there is no switch, only the steps. A big screen's game page has no switch: the steps and the video side by side, both following the chip. The pill on the picture, the button after the steps and the layer over the sheet are gone.
| Question | Answer |
|---|---|
| One piece | ui/useHowTo (the chip and read-or-watch), ui/HowToSwitch (the switch in the heading's row), ui/HowToVideo (the frame) behind ui/HowToPlayBody, the one body the sheet and the page share |
| It stops | Switching to Read, another chip, closing the sheet, leaving the page |
| Fetched | Nothing from the media host until the video is showing; then its picture, and the video on the tap |
| It cannot load | The coral says so in one line where the video would be; nothing else on the card changes |
| The hotspot mode | No internet, so no switch: steps only (offline) |
| The switch's look | Small and quiet, the picked side filled in ink, a little picture on each side, so it never reads as the yellow mode chips under it (those choose how to play; this chooses how to learn) |
Why: Karim tried the build below and rejected it: "I don't like to show two buttons for the same thing… I also didn't like the way we are opening the video in another modal because we are already showing it in a modal." On a big screen "we don't have to show the toggle… because we have space there."
Rejected: two ways in to one video; a layer opened from a sheet; the switch on a big screen, where both fit. (The 2026-10-13 entry rejected a Watch / Read switch for hiding the rules while watching. That was the wrong worry: hiding them is the point of choosing, and the steps are one tap away.)
What would reverse it: people never switching away from the video, at which point the steps could sit under it again.
Later the same day, the layouts (Karim's pick of three, tried live): the rules sheet on a big
screen is a SPLIT dialog (about 920 wide): the video on the game's own ground on the left, the name, facts,
compact chips and steps on the right, both at once, no tabs. On a phone it is a SLIM HEADER (the card's still,
the name, the tagline, the facts in one row) with quiet underline tabs, "Read rules · Watch video", and the
video as big as the sheet has room for. The game page reads like the phone sheet on a phone and puts the
steps beside the video on a big screen. Mode chips are compact everywhere in the card (sized to their words,
44px to the touch, one row that scrolls sideways). The beside-the-heading switch above is gone. Both are the
real Modal, with two small options: width (the split's wide dialog) and
bare (the layout draws its own header; the frame keeps its ✕, grabber, swipe and foot).
Rejected: option 3, the picture band turning into the player.
And the game page, same day: no "About this game" (it repeated the rules; the description
still names the page for search engines), no line under the start buttons, "More games" as four of the home's
own cards (the same kind first; four, because each card is a drawn scene inside the built page, and all of
them made a page seven times heavier), and the buttons say what they do: "Play online with friends", "Pass one
phone around". A start button carries its game all the way in (Karim: "The user's intention
is to host an online room and play the game"): one phone opens its set-up with the game picked, and
/host?game=<id> keeps the game through the sign-in and, once the room is made, writes it as
the host's pick through the lobby's own path (rooms.updateSettings, the host's mirrored picks),
so the lobby opens on it. It never starts the game: a room needs friends first. Rejected: a new server
function to create a room with a game (the existing pick does it); re-picking in a room the host already has
open (it may hold a party they were building).
Superseded on 2026-10-14, for the video, by the entry above: no
pill, no button after the steps, no layer over the sheet, no Modal width or
covered. The rest of this entry stands: one file per mode, a game's card leading to its page, and
the "?" on every small still.
Decision: the how-to video never plays inside the rules card. The card looks as it did before videos, plus two ways in: a sky pill, "Watch how to play", hanging on the bottom edge of the game's picture (half on it, half below), and a quieter button, "Watch the video", after the written steps. A tap on either opens the video in its own layer OVER what asked for it, which stays standing exactly as it was. This replaces where the video sat in the entry below (same day); where the files live and how they go up are unchanged.
| Question | Answer |
|---|---|
| The layer, on a phone | A bottom sheet over the rules sheet: the video edge to edge in its 4 by 5 shape, as large as fits, then one Close. It also goes with the sheet's own swipe |
| The layer, on a big screen | A centred dialog the size of the video (at most about 80% of the window's height), with one ✕ |
| One close per layer | The viewer has its own way out. The dialog it covers hides its own ✕ (Modal's covered), so the screen never shows two. It hides it only once the viewer has said it is really on the screen (onShown), never on the tap alone: a layer that failed to come up must not take the way out with it |
| It is the app's one dialog | ui/Modal, stacked the way the remove confirmation is stacked over the Players sheet (mounted beside it, marked stacked), with three small options: width, so a big screen's dialog can be the size of the one thing it shows, and covered and onShown for the row above. No second dialog was written beside it. Not proven on a tablet in the phone app: there the two are platform dialogs side by side, and the platform may refuse to raise the second while the first is up. The remove confirmation over the Players sheet has the same shape and the same doubt |
| Which video | The one for the mode being read about. Every variation has its own video: the mode a game opens on keeps the game's plain file (tic-tac-toe), every other mode has <game>--<mode> (tic-tac-toe--vanish). The list is games/howToVideos.ts, checked against each game's own mode names. A game or a mode with no video shows neither way in |
| Before a tap | Nothing is fetched from the media host, not even the picture. The picture comes with the video and fills the frame until the first frame does |
| It cannot play (no internet, a broken file, an app built before the phone's player) | The coral says so in one line, in the viewer, and the layer's one way out stays its one way out (Close on a phone, the ✕ on a big screen). Never an empty box |
| The hotspot mode | It has no internet by definition, so its card offers neither way in (offline on the sheet and on the play header) |
| One piece | ui/HowToWatch (the two ways in), ui/HowToViewer (the layer) and ui/useHowTo (what is being read about). The rules sheet and the site's game page both use them, so they cannot drift |
Why: with the video at the top of the card there were two pictures in a row (the game's card, then the video's title picture) and the written steps fell below the fold. The first redesign Karim tried played the video in the picture's own place, growing where it stood, and then the video's ✕ sat beside the sheet's ✕: "It is looking like there are two close buttons: one inside the game video and one outside for the modal." His call: "we can combine option A with option C and show the game in the modal. The default one will be shown like how we have shown option A, but below there is also text for how to play. After the steps, show a 'Watch Video' button… Clicking on it will open that in the modal." The picture carries the way in for someone who would rather watch; the button after the steps is the same way in for someone who read first.
And a game's card now leads to its page. On the home and the Games tab a tap on a game
opens /games/<id>, on the web and in the phone app. The page is rebuilt round the same
idea: the two start buttons sit in the hero under the facts ("Host an online room", "Play on one phone",
which carries the game into its set-up), the picture wears the same pill, How to play is the same body as
the sheet, and the long description is a quiet "About this game" near the foot (still in the built page, for
search engines). The small "one phone or online" sheet the home used to open
(2026-09-01, below) is deleted: the page already offers both, and a sheet that offers what
the next screen offers is clutter. In the room's games sheet every small still now has a visible "?" at the
far end of its name, beside the still's own tap and never inside it; before, only a hidden long press opened
a small still's rules.
Rejected:
What would reverse it: players not finding the video (then the pill is too quiet, not the layer wrong); or the online door learning to carry the game, at which point "Host an online room" on the page should open the room with that game picked.
Superseded the same day, in part, by the entry above: the video no longer sits at the top of the card or plays in it, no picture is fetched before a tap, a failed video is said by the coral in the viewer, and every mode has its own file. Where the files live, the version and language folders, the site's security header, the sound switch and how a video goes up all stand.
Decision: every game that has one shows a short video (about 25 seconds, upright, 4 by 5)
at the top of its how-to card: a voice explains while the game is played on screen. The written steps stay
exactly as they are, under it. It lives in ONE place, ui/HowToPlayBody, so the how-to sheet in
every mode and the site's game page show the same thing. At rest it is the video's own title picture with one
yellow play button, low in the picture so it never covers the game's name. A tap plays it in the same frame,
with sound and the platform's own controls. Nothing but the picture is fetched before the tap.
| Question | Answer |
|---|---|
| Where the files live | https://media.gamesforgroup.com/how-to-play/v1/en/<game-id>.mp4 and the same name ending .jpg for the picture. A version folder, then a language folder |
| Which games, and which mode | One list, apps/app/src/games/howToVideos.ts. A video teaches one way to play: the mode the start sheet opens on. A game with modes shows its video only while that chip is the one picked, for the same reason its steps follow the chip (2026-09-11, below) |
| No internet (one phone offline, the hotspot mode) | The picture cannot load, so the whole block draws nothing and the written steps stand alone. The same if the player fails |
| The site's rules for other hosts | The site's security header allows that one host for pictures and for video, and nothing wider (scripts/build-site.mjs) |
| The app's sound switch is off | The video still plays with its voice. The switch is for game sounds; a tap on a video's play button is the ask to hear it, and the player's own mute button is in its controls. Every video also carries short captions |
| A picture that never arrives | After four seconds the block steps aside, so a stalled connection never leaves an empty frame over the steps |
| Putting a video up | One command in the marketing repo, tools/publish-how-to.sh: it cuts the phone-sized file and the picture and puts both in the file store (releasing) |
| An app built before the player was added | The phone's player is a native part (expo-video). Its code is fetched only after the tap, behind a net, so an older build shows the picture and then steps aside instead of stopping |
Why: players do not read rules. A voice lets them watch the board while they listen, which a list of steps cannot do.
Rejected:
What would reverse it: a second language (the address already has a language folder, so it is a new folder and one more answer in the list), or the dark look needing its own cut of the videos.
Decision: a scene to be in (ui/scene) is a place, and a place
has art rules of its own. A card or a button is a flat candy shape with a bold edge. A place is a room with
light in it. The living room (ui/scene/living) is the first scene drawn to these rules and is the
worked example. The pool, the backyard and the dance floor are still drawn the earlier way and follow.
| Rule | What it means | In the living room |
|---|---|---|
| Two weights of line | The bold dark line is for mascots and for things you use or sit on. Everything behind is a thin line in a darker shade of its own colour, or no line | The sofa, the lamp, the popcorn and the radio wear the ink. The wall, the window, the shelf and the rug's pattern do not (living/paint.ts) |
| One light | One source at each hour, and a shadow under everything it reaches. Light may be soft-edged. Objects stay crisp. | By day the sun through the window, thrown on the floor as its four panes. At night the moon, darker corners, and one soft pool round the lamp while it is on (living/Light.tsx). Everybody on the floor lays a soft shade (Scene.shade) |
| One viewpoint | The floor is seen from above. Every object shows its top and its front | Floorboards run back from the eye. The table shows its top with a darker near edge |
| Calm walls and floor | A small warm palette, each colour with a lighter and a darker step, so the mascots are the brightest things in the picture | Night is the same room relit in blue, never the day room under a veil |
| Details that say who lives here | Small true things, quiet and in the background | A shelf with one box for each game the app offers, a clock that tells the real time, a cat asleep, a poster, a plant, books and a photo |
| Nothing fully still | Ambient loops are allowed in a place. They stop under reduced motion, in a small still picture, and while nobody is looking | Curtains breathe, clouds drift, fairy lights twinkle, dust hangs in the sunbeam, a robot vacuum trundles (living/life.ts). Each is a keyframe loop the platform runs, so no script runs per frame |
| The room's facts are things in it | A scene may draw the room's name, its count and its code itself (Scene.Fittings, handed RoomFacts). The header row then keeps only the way back and the menu | The name is a painted wooden sign on a nail, with a small number on it for how many are in. The code is the app's own yellow tiles pegged to a string of fairy lights |
| A delightful control keeps an obvious twin | A thing in the room may do a real job, and the ordinary button for that job is always in plain sight too | The wall switch flips light and dark and the radio turns sound off and on. Sound, Light or dark and Share also hang under the sign as the app's own three buttons |
The room answers real events only. A knock: the door rattles and everybody looks at it. An
arrival: everybody looks to where the newcomer is going and the three nearest wave. What the host picks: that
game's box is out on the table, with a trophy for a tournament and a garland, a hat and balloons for a party.
Start: the fairy lights race and everybody cheers. A friend's phone going away: they doze where they are.
Nothing in the room acts out something that did not happen (ui/scene/gaze.ts,
RoomFacts.playing).
Why: Karim, 13 October 2026, on the scenes as first built: "these are looking good, but not looking professional. These are looking like they were designed by some noob or amateur. I wanted it to feel like this was designed by a professional studio or a big company." A sketch of the living room with light, shadow, quiet walls and details got: "This is the perfect direction you are going. The details are what we're missing... push even further." On the header: "The top part, which says the room name and the count, is not looking good... integrate these elements into the room itself. The room name can also be a wall poster." After playing it: "I liked the living room design. The details were spot on." The card rules were written for a thing you press, where flat and bold reads at a glance. A place is looked into, and the same rules made it paper with stickers on it.
Three of the rules came from his notes on the built room. The switch and the radio were "kind of a hidden Easter egg... there should be obvious buttons for the users to toggle those." The count's own tag was "an unnecessary field. Maybe just a number on the board somewhere is enough." The share tile beside the code "is not looking like a share icon. It looks like part of the code", so nothing but the code hangs on that string. And the lamp's light was "too sharp on the floor... we need a soft glow there... The shadow above the lamp is not looking good", which is where "light is soft, objects are crisp" comes from, and why nothing is thrown up the wall.
Rejected: an AI-painted picture as the background: it blurs on a big screen and nothing in it can react, so the lamp could not go off and the door could not rattle. The earlier rule for scenes, "flat colour, no shading, no shadows": it made stickers on paper. The switch and the radio as the only way to change the look and the sound: a control nobody can find is not a control. Keeping the header row over the scene: boxes over a room he asked to have in the room. A tag for the count, and Share as a tile on the code's string.
What it costs: a place is many more drawings than a card, and each new scene has to be drawn to the same finish. The app's rule "no decorative loops" now has one exception, the place, and the three conditions that stop a loop are part of it. A scene that draws the room's facts must lay out its own tap targets and keep clear of the room's buttons in the top corners.
What would reverse it: the loops costing frames on a real phone. Then the loops go first and the light and the details stay. A scene whose walls have no room for a name and a code keeps the header row, which is still there for any scene without fittings.
How he judges this kind of work is in Karim's taste. What the room does today is in the frontend page.
Decision: a room waits in one of four scenes: the living room, the pool party, the backyard and the dance floor. The dome hall is deleted: its folder, its entry on the app's list of scenes, its id on the server's list, and the words only it read. A room that had stored it is unharmed: the room's row keeps the scene as a plain string, and the app draws the living room for an id it does not know, which is what such a room now shows until its host picks again.
What Karim liked in the dome hall, its zip line, is the backyard's second ride, and the slide stays. A cable runs from a platform in the apple tree, over the fence and along the top of the garden, to a post behind the bouncy castle. A little trolley waits on the cable over the platform and goes down with whoever takes it; they hang under it upright with both arms up, the name under them level, and cheer on the way down. Over the castle they let go, drop in behind its scallops, bounce once, and the castle gives under them; the trolley runs back up the cable by itself. The ride takes a second and three quarters on every screen. The trolley, the cable and the post are the dome's own drawings, moved.
So a scene's rides are a list, in the scene's own order (Scene.rides), and
each is ridden by itself: its own tap targets, its own one rider on the way down at a time, its own landing
places, its own thing to hang from. A move says which ride it was by: a small whole number,
counted from one, in that order (room.go's r; a snapshot's row carries the same).
Where somebody stands still keeps the number of the move that was a ride, so a second ride to the
same spot is still a trip, and carries which ride beside it (Standing.ride and
Standing.by). The first ride on the list is the way in: a newcomer comes down it, and with its
foot full they come in on foot, never by another ride. A rider who finds every landing place of their own
ride taken climbs out to the crowd, whatever room the other ride has.
Two things guard the number. Where a message is read it must be a whole number from one to
eight (places.rideNumber), and anything else there is a malformed message, refused like a
spot that is no count. And a number the scene has no ride for (a tampered phone, or one on a
newer build whose garden has a third ride) is read as no ride at all: the move stands, the person is simply
there, and every phone with that scene reads it so. What a phone keeps is what was said, whichever
rides its own scene has; the question "does this scene have that ride" is asked when the room is worked out
(places.whereOf), never when a move is taken in.
For the zip line a ride may now say three things a slide into water never needed: how long its way down
takes (takes), how high the hop up to its top goes (rise: its top is at the top of
the scene, and a full hop took riders out of the picture), and that it ends in a drop
(drop): the last stretch of its way is fallen, the rider lets go where it starts, and from then
on is drawn as deep in the scene as the place they will land in, so the castle's towers are in front of them
as they fall in. A ride has an id like every other thing in a scene, and a drawing can read how often it has
been ridden to its foot, which is how the castle knows to give. Whoever holds on to something holds their
arms up for as long as they have it (the mascot's own cheer only lasts a second). What only the dome hall used
in the kit went with it: a small picture that starts higher, a door upstairs, and a swing's own rope
length.
The host's pictures of the scenes are in even lines on a big screen, three across at the most: four are two and two, from edge to edge of the card, and no picture is ever alone on a line. The room's form is never taller than the scene it lies over: in a window too short for it, it scrolls inside its card.
Why: Karim, 13 October 2026, on the three new scenes: "It looks good, but the dome is not looking good. The backyard and dance floor are good. Maybe we can remove the dome and have only four options: living room, party pool, backyard, and dance floor. What I like in the dome hall is the sliding with the wire, like a zip line. Can we have that zip line in the backyard as well, if it suits?" (the card is in Karim's taste). A move has to say which ride because who is where is agreed between phones by rule: with two rides and a move that only says "by a ride", each phone would draw the one it guessed. It is a number in the scene's own order, and not a name, because a number held between one and eight can only ever be used as a place in a short list, and every message stays as small as it was. And a ride the scene has not got moves the person all the same, because where somebody is must never depend on how they got there: two phones on two builds then still agree where everyone stands, and only disagree about a ride one of them cannot draw.
Rejected: replacing the slide with the zip line: he asked for it "as well", the slide is how a newcomer comes in, and the ball pit is there for it. Keeping the dome hall behind a switch: he asked for four, and what is deleted is still in the history. The zip line ending in the ball pit (the slide's) or on the trampoline (a cable to it crosses the slide). Starting it at the far right, over the gate: the gate's arch is as tall as the platform is high, and on a small phone there is no sky left above it; so the tree stands beside the gate, and nearer the castle on a wide screen, where the cable would otherwise run nearly level. Refusing a move whose ride the scene has not got: the phone that made it and the phone that refused it would never agree where its person is. Forgetting the ride as such a move is taken in: two phones would then hold two memories of one move, and a snapshot from one would overwrite the other in the middle of a ride. One chute for the whole scene: a rider on the slide would hold up the zip line. A mast on the platform for the cable to start from: its top was under the room's header on a small phone; the cable is tied in the tree. Three pictures and one alone on the second line, or two and two at the old small size with empty card either side.
What it costs: a scene's rides can only be added to at the END of its list, and never reordered or taken out of the middle, or phones on two builds would name two different rides by one number. The top of the garden is fuller than it was on a small phone (the sun, a cloud, the cable and the tree share fifty pixels of sky there).
What would reverse it: scenes that are sold, or built by others, wanting rides that come and go: then a ride is named by an id of its own on the wire, and the number goes. A scene with more than eight rides. Karim finding the backyard too full with two rides: the zip line is one entry on the list, its drawings are one file and one trolley, and it comes out without touching the slide.
Deploy order: the backend first (bunx convex deploy: the server's list of
scenes changed), then the site and the apps. The wire's own change needs no deploy: the relay passes
messages on and reads none of them. An app from before this hears "ride two" as no ride and draws the rider
simply there, in the right place.
Decision: the backyard, the dome hall and the dance floor were drawn at the same time by
three builders, and each added what it needed to the scene kit. Merged, the kit has one of each. One
swing: it turns about the top of the box its seat and ropes are drawn in, and a place says how
far up that is (hangs), so a garden swing and one hung from a balcony are the same code with
different rope lengths. One bounce for a trampoline: it reads the clock as it is, so with the
clock stopped (reduced motion, a small picture) everyone is on the mat. One landing for a ride
that ends on dry ground, mounted once at the ride's foot. The dance, the rider's grip and the ride's lean had
one builder each and stay as built. Three smaller things that more than one builder had written for their own
scene are the kit's too: a drawing stood at a point of the scene (ui/scene/Stood), things thrown up
from a point that fall back, which both ball pits use (ui/scene/Thrown), and "once each time a count
goes up" (ui/scene/useOnCount). The host's pictures of the scenes are a row that slides sideways on a
phone (two whole, the next peeking, opening on the scene the room is in) and three to a line on a big screen.
Why: two swings that differ by a number are two things to keep in step by hand, and the second one is always the one that is forgotten (the rule at the top of the project's card). The row slides on a phone because the list will grow: the form stays the same height at any count, so Save never falls off a small phone. A big screen has the height and a mouse does not slide a row, so it gets lines.
Rejected: a rope length written into the kit for each scene (the kit would know its scenes); two people on one trampoline out of step (with one clock that stops at nought, whoever is out of step hangs in the air in every small picture, and the builder's two were in step already, standing on one share of the box); five pictures squeezed into one line (names cut to two letters, pictures too small to tell apart); two lines on a phone (a third line arrives with the seventh scene and pushes Save off).
What would reverse it: a scene whose swing must go further or slower than the others would
say so on its place, beside hangs, and the kit would still have one swing. A big screen's lines
would become a row too if the list outgrew the height of a laptop.
Decision: ui/room is gone. The old Room with its TV, the stand and
its keys, the wall posters, the sofa seating and its layouts, the programme it showed on the TV, and the dev
page /room-preview (the last thing that drew it) are deleted, with the words only they read and the
mascot's TV remote and sign pole, which only that room's people held. On the server
players.sit is gone, the roster no longer carries a place, and the field
players.seat leaves the schema. Nothing a player sees or can do changed.
Why: nothing in the product had mounted it since the lobby became a scene (2026-10-12) and a party got its own screen between games (the entry below). Code nobody runs still has to compile, turns up in every search and has to be kept in step, and a folder named "room" that is not the room sends the next reader to the wrong place. There are no old phone builds and no old rooms to keep working until launch (Karim, 2026-10-12: "no old rooms, no old users, migration and all... You are free to do anything"), so nothing is kept for them.
Where what was still used went. Moved, never copied. The living room's drawings (wall and
floor, door, sofa, rug, bean bags, popcorn) and its colours are in ui/scene/living, and they are
now handed only the boxes they read: the adapter that filled in an empty TV, stand and posters for them is
gone. The outline and paper every drawn thing shares: ui/scene/ink.ts. The bubble pinned to a
point: ui/scene/RoomBubble. The door's facts and the colour of a knock:
ui/playroom/door.ts. Whose vote counts: ui/playroom/votes.ts. The counter that says
the room's people are in front, which is when a phone watches who is here and checks in every 15 seconds:
ui/playroom/PeopleOnScreen (it was SeatsOnScreen, and behaves the same).
The field leaves in two deploys. Convex refuses a schema that has lost a field while any
row still holds it. Deploy one removes the function and every reader, keeps the field optional, and adds
players.clearOldSeats: a job that walks the players table a page at a time (200 rows a run, the
next page booked through the scheduler) and takes the field off each row. It is run by hand, once on each
backend. Deploy two removes the field and the job. It is refused while any row still has a seat, so it is
also the check that the job finished.
Rejected: keeping the field "in case" (a field nothing reads is a question for every later reader); dropping the field in one deploy (it fails on the first row that has a seat); keeping the old room as a dev page (a page that draws a room no player can reach is kept working and connected to nothing).
What would reverse it: nothing brings the TV back. A scene that wants people to keep a place between visits would store it on purpose, under that scene's own names, and would not revive this field.
Decision: from the moment a party starts to the end of its awards, nobody sees the room.
Before the first game, after every game and after the last one, both shells show the party's own screen
(ui/party/between AfterGame), fed by two thin adapters
(modes/online/party/PartyAfterGame, modes/local/party/PartyAfterGame) from the same
facts (ui/party/between/facts.ts). Karim: "when the party started, why will they come back to
the lobby again...? If the party's first game is over, they might see a standing screen after the game, and
that game should have a timer. They should not come back to the lobby. They should come back to the lobby
only after the party is over, or after the tournament is over, or after a single game is over." The room is
for before and after.
What the screen says. The stage holds only the winner of the game just played, on one gold step, saying so ("I won Ludo!"); friends who won together stand on that one step and say it together; a draw, a game that stopped early and a game that could not be played are said in one line by whoever speaks for the party (the host; the first of the table on one phone). Everyone else stands below as a whole mascot with their name, and nobody is drawn twice. Nothing is counted: no wins, no ranks. The awards sum the night up once, at the end. Under them, what is up next with its countdown, tonight's list behind a tap, the host's knock card in a room, and "Add a friend" on one phone.
Pause replaces Skip. Karim, on the preview: "instead of skip this game, we should have pause option, so that if they want to take a break before starting a next game they can do so. Skip I think is not needed, they have set up the games by their own will and order." Whoever runs the night has a quiet Pause and the yellow Start now. Pause stops the countdown, every phone shows it stopped and says who will start it, and the quiet key becomes Resume. Resume starts a whole countdown, never the seconds that were left, so whoever stepped away has time to get back. Start now starts the game at once, on a break or not. Skip is gone from every screen, between games and during one, and from the rules: the engine's party has no event for it. A game is passed over only when it cannot be played (it no longer fits the room, or it refuses to start), and the night says which.
In a room the break lives on the server. The programme's row says the countdown is held
(pausedAt) and has no start time while it is; programmes.view serves
paused, so a phone that reloads or is let in lands on the break. programmes.pause and
resume are the host's alone and go through the programme's one writer
(lib/partyRun.ts). Only a party between games has a countdown to hold, so the guard is
written as that state: a pause while a game is on, in a tournament, or once the night is over does nothing,
and neither does a second one. The job booked for the countdown is remembered on the row
(startJob) and cancelled by a pause, an early start or a change of game; a job from before a
pause that wakes anyway finds the hold, or a countdown that is not its own, and starts nothing. Resume books a
new job for the new moment, so nothing is polled. A room is allowed ten pauses a minute
(limits.ts pausesPerRoom): each one that takes rewrites the row the whole room
watches. On one phone the break is a saved field of the party, so a phone that locks on it comes back on it.
A break waits for the host, however long. It has no ceiling and never ends by itself. That is the standing rule, not an oversight: the host is in the room or nothing starts ("no new game starts until the host is back"), because only the host pays and so only the host runs the night. A host whose phone dies on a break comes back to the same break and resumes it. Meanwhile a guest sees the clock stopped and who will start it, and can wait or leave; nobody else can start the game and no timer does it for them. A room everybody has left is put away like any other silent room.
After the last game. That game's winner has the same moment every other winner had: the
same screen, with the awards up next and a countdown to them. It is one more step at the front of the show the
server already moves (showSteps.ts, the step after): the server stores the moment it
brings the curtain by itself and every phone counts down to it; the host's Start now is the show's ordinary
one step. There is no break to take here. A night that ends because nothing left in line can be played has
had its countdown already and opens straight on the curtain.
Why: a party is one evening with a shape of its own, and sending everyone back to a lobby after each game made it read as several separate games. A countdown that can be held is what a group that wants a drink before the next game needs; passing a game over is not, when the group picked the games. One winner on the step is a moment for that friend; a tally under them turned every game into a league table, which is the awards' job.
What we rejected: the living room between games ("next up" on its TV), which this replaces in both shells; a podium of three; a "Wins so far" heading with each friend's wins as their eyes ("is not making any sense to me, let's remove it"); Skip, between games and in a game's menu; Resume carrying on from the seconds that were left (a group back from a break would find two seconds on the clock); the host's phone keeping the break and the server only following (a host whose phone died would hold or release the room by accident); a second mechanism for the hold beside the countdown's own (one field and one job are the whole of it); a break before the awards; a ceiling on a break (resuming by itself after some minutes when the host's phone has died), which would start a game with no host in the room.
What would reverse it: groups asking to drop one game mid-night (then a way to take a game
out of tonight's list, not a skip key); a room whose host is often away at the countdown (then the server
holds by itself until the host is back, on the same field); wanting a break before the awards (the same
pause, pointed at the show's first step). Tests: convex/programmes.pause.test.ts,
convex/programmes.party.test.ts, packages/engine/src/programme/party.test.ts,
modes/local/party/partyRun.test.ts. Not deleted in this step: ui/room itself, whose
drawings the living-room scene uses.
Decision: a room waits in a scene, and its host picks which: the living room
(scene one, and the scene of every room that never chose) or the pool party. Everyone in the room sees the
same one. The pick is one optional field on the room document, rooms.scene, holding the scene's
id; absent means the living room, so no room made before scenes and no phone on an older
build needs anything. It is set through rooms.updateDetails, beside the name and the size and
under the same checks (the host only, and only while the room is in the lobby), and it must be one of the
ids the server knows (convex/lib/roomScene.ts): anything else is refused before a row is read.
rooms.get hands it to everyone who can read the room's name. The app has one list of scenes
(ui/scene/scenes.ts: the id, the name, the scene itself), typed by the server's ids, so a scene
known to one side only stops the type-check; a new scene is one entry there and one id on the server. Both
scenes are free. Who may use a scene is not asked anywhere yet; when some are sold, the server asks it of the
host's account in updateDetails, and the picker only shows the answer.
The host picks from pictures, in the room form that already holds the name and the size
(ui/playroom/RoomForm): each scene drawn small from its own drawing with three friends in it
(ui/scene/SceneThumb: the stage's own layers laid out for a small phone and scaled down, never a
picture file), the one that is on in the kit's chosen-card ring with a tick. A tap moves the room at once. On
a big screen the form stays, beside the scene; on a phone it is over the scene, so once the room has moved it
gets out of the way (unless a new name or size is half typed). A guest has no form and never sees the
picker.
When the room moves, every phone hears of it from the room document, and on each everybody is simply there in the new scene, at once: seated, or in the water, where that scene welcomes them. Nobody walks in, nobody rides the slide, nobody waits a turn. Where they are is the scene's welcome order, which is read off the roster alone, so every phone draws the same picture the moment it hears, with nothing said between phones; from there people move by tapping, as ever. On the wire each phone starts its talk again for the new scene on the socket it already holds, asks what it missed there, and ignores whatever is said about another scene; a little later it says one small thing, that it keeps the place it stands in, which changes nothing drawn and only stops people shifting when a friend leaves.
An entrance is for somebody coming into the room, and for nobody else. Through the living
room's door, out of the pool's hut and down its slide (with the cheer on the way down): that is played in
exactly one case, for one person, once, on every phone: somebody who was not in the room and now is. The
phones that watched it see the roster gain a person; that person's own phone is told by the two places somebody
comes in from (the room's door, where a guest waits until the host lets them in, and the host's page as it
opens a new room: modes/online/newcomer.ts), because a reload, the end of a game and a real arrival all open
the lobby the same way. That word lasts until the person is in the room, whatever the room is showing, and no
longer: it ends when the door's own screen goes, never on a timer. So somebody let in during a game has simply
been in the room since then, and is there when the lobby next opens; only somebody let in while the lobby is
showing comes in through its door. Such a person is not in the picture until their own first move is heard, and is then
drawn coming from the door to wherever it takes them, in one go; with nothing heard in three seconds they walk
in anyway. It is never played on a change of scene, when everyone comes back from a game, on a reload or a
reconnect, or for the people already there when somebody opens the room. And when a phone is told
where people are (a snapshot), they are simply where it says: nobody is drawn walking, or riding the slide
again, to a place they were already in.
The same roster and the same places are the same picture on every phone, the open ground
included: the crowd stands in the order people came into the room, and who is looking never changes where
anybody stands, because friends point at the screen ("I'm next to you"). A phone's own person used to be moved
to the crowd's front row so nobody stood on the "You" under them, which put them, and everyone after them, in
other slots than every other phone drew. They now keep their slot and are drawn one layer over the crowd
instead (ui/scene/rests.ts).
updateDetails now writes only what changed, and counts what it writes: thirty
changes to one room in a minute (a safety limit, changesPerRoom), refused past that with "One
second, then change it again." It had no limit before, and every write to the room document wakes every phone
in the room. Changing the scene adds no query and no watcher: it rewrites the one document
rooms.get already reads.
Found in review of the step before, and settled here: a made-up count cannot leave a lasting toy
dead. How far on the lamp is travels as a count, the furthest anybody says wins, and nothing proves
who tapped; said at the highest number there is, no tap could ever follow it. What is said of a toy is now
believed no more than 10,000 past what the phone itself holds (places.toyTold), in a snapshot and
in a tap alike, so wherever a forgery puts the count the next real tap is one more and every phone that heard
the forgery believes it. What it costs: after such a forgery a phone that comes in later is told the toy a
step at a time, and may show the lamp the other way round until it has caught up.
Why: Karim, on the scenes: the host picks, everyone sees the same one, the living room first; more scenes may be sold later, both are free now; and nothing "basic", so the picker is pictures, with the one that is on plain to see. The scene goes on the room document, where who-is-where does not, because it is a different kind of thing: a fact about the room that must still be true for whoever comes in an hour later, or reloads, alone. It changes a handful of times a night by one person's tap, so it costs one write where a walk across the room would cost thousands. On who comes in, Karim, the same day, after playing a change of scene in which everyone came through the door one after another: "directly place everyone on the seating or pool and switch between the scenes… entry, sliding, or jumping onto the sofa should be for the new place or joining the room… If we do this for every player when it's in the scene, it will not look good" ("The room is a place to play in"). A welcome order that needs no messages is also what keeps the phones agreed: there is nothing to wait for and nothing to miss.
Rejected: keeping the scene on the live channel with the places. The relay keeps nothing: the pick would be gone when the last phone looked away, a phone that opened the room alone would have nobody to ask, and two phones could sit in two scenes until a snapshot passed. A table or a query of its own (new watchers for one small field). A scene on every save of the form: a backend from before scenes refuses the whole call, and a rename would fail with it, so the scene is sent alone and only when it changes. Showing the new scene before the server says so (it would flick back when refused). Everybody coming into the new scene one after another, each by a move of their own in turns read off the roster: built, played by Karim and turned down the same day (above); with ten people it is a queue at a door, with thirty it took twenty seconds, and it needed thirty messages before the phones agreed. Telling a person's own phone that it has just come in by what the room says (no move of theirs in a snapshot): a reload in an empty room, a phone that opens the lobby a moment after the others when a game ends, and a real arrival all look alike that way. A flat ceiling on a toy's count in the wire's readers: forged to the ceiling itself, the next real tap is over it and no other phone believes it. A list of scene names, or picture files (a file goes stale the day a drawing changes).
What would reverse it: scenes that differ per person (a scene each phone picks for itself is not a field on the room). Wanting a scene remembered as people left it (today going there and back seats everyone afresh): then where people stand is a fact, and facts are stored. Scenes being sold: the question of who may use one is added on the server, and nothing else moves.
Deploy order: the backend first (bunx convex deploy), then the site and the
apps. An older app against the new backend is fine: it does not send a scene, and it draws the living room
whatever the room says, so in a room moved to the pool a phone on an old build still sees a living room and
its moves and the others' pass each other by. The new app against the old backend also works, as far as it
can: every room is the living room, a rename saves, and a tap on a scene is refused whole and says nothing.
So the wrong order breaks nothing, and only the right one lets a host change the scene.
Decision: the room where friends wait is a scene host (~/ui/playroom
PlayRoom): the room's name, code and Share, the door that is the knock, a scene to be in, and
what is going to be played, behind one bar on a phone and in a side panel a third wide on a big screen. It
draws and hands taps back, and owns no decision. A scene is data plus drawings
(~/ui/scene): its named places and how many each holds, its open ground, the order it welcomes
people in, its door, a ride, its toys. The living room stays, as scene one, with its TV, stand and posters
gone (the bar and the games sheet do their jobs) and drawn with the room's own drawings; the pool party is
scene two. One shared phone gets no room at all: it keeps its own set-up screen. Everything about choosing a
game is the games sheet (~/ui/gamesheet): the host's tap picks, a guest's tap is their vote.
Who is where is not stored anywhere. Each phone works the room out by rule
(~/ui/scene/places.ts) from two things it is told, in whatever order they reach it: who is in
the room, and the moves people have made. One rule governs where a claim to a place may come from: a
phone only ever makes a claim for its own person. What is kept for a person is the latest move they
made themself (their highest-numbered one) and nothing else. Two whose moves claim one spot are settled by
the lower number, then by who came in first, and the other stands in the crowd; the numbers are each
person's own count, not a clock, and the rule is there to be the same on every phone, not to be fair. Anyone
nobody has heard a move from stands in the scene's welcome order, read off the roster and seated after every
move, so they never take a spot somebody chose. Arriving is the arriver's own first move: their own phone
picks where they land (down the pool's slide, to the living room's best free seat) and says so like any
other move. What a phone remembers is only what it has heard: who is in the room is applied when the room is
read, a roster change deletes nothing, and whoever leaves and returns comes back where they were, counting
on from the highest move the room holds for them. What is guaranteed is this: two phones that have heard the
same moves and hold the same roster draw the same room, whatever order those reached them in and whichever
states of the roster each saw on the way. Two things are not guaranteed, and heal instead. A phone that
missed a message draws a different room until the next snapshot anybody sends, which brings it level. And
someone who comes back is where they were once a snapshot reaches their phone: until then their own phone
draws them where the room welcomes them. A message that makes
no sense is ignored. The cost is accepted: on a phone with no channel
only its own person moves, so when a friend leaves, the people after them are welcomed afresh and may shift
a place; with the channel everyone has a move of their own from the moment they come in, and nobody shifts.
Phones agreeing outranks people standing still.
The room is handed all this through one interface (Places) and cannot tell how a move
travels. One phone's memory of it is useLocalPlaces, and the online lobby
(modes/online/Lobby.tsx, which mounts PlayRoom) puts the room's live channel round
that memory (useRoomPlaces). The channel stores nothing, and a phone with no channel still draws
everyone where the scene welcomes them. ~/ui/room, the living room with its TV, stays as the
room both shells draw between two games of a party.
On the wire it is four messages (modes/online/roomWire.ts). A move
(room.go: scene, place, spot, the mover's own count, a ride marker) never names its mover: the
relay stamps each message with the player its socket was let in as, so a guest cannot move anybody else.
Something short-lived (room.do: a hello, a bounce, a toy) is never replayed; how a lasting toy
was left (the lamp) rides in the snapshot. A phone asks what it missed each time its socket opens
(room.hello), and is told with a snapshot (room.where): everybody's latest move, the
old move of whoever asked even before the roster says they are back, and the toys. One phone
answers: every phone that knows the room waits its turn (the host first, then the order people came
in, a quarter of a second apart) and stands down when it hears somebody else's snapshot. A phone makes no
first move until it has been told, or 1.2 seconds have passed since its socket opened; told by nobody, it
keeps the place it was welcomed to, which is what makes everybody opening the room at once (a game has just
ended) come out as the room every phone had already drawn. A phone that knows more than a snapshot says
sends its own, in its turn, so the phone that answers is healed like any other. And a room in which no
snapshot has passed for 150 seconds is told again: that re-agrees phones nobody noticed had drifted, and
keeps the relay from closing a silent room after three minutes and making every phone reconnect and ask the
backend for a pass. A move makes no backend call. In the old room one seat move was one
mutation (players.sit) and, because it wrote the mover's roster row, a re-run of the roster
query on every phone in the room: nine function calls in a room of eight, before counting the other queries
that read the same row (players.wants and rooms.get on every phone,
players.me on the mover's: up to twenty-seven). Now it is none; the only backend call the
channel adds is the pass a phone asks for when its socket opens. players.sit and the row's
seat stay on the server, unread by this lobby, for phones on an older build.
Two things guard the service and the people in the room, found in review the same day. The
bill: every connect asks the backend for a pass, and the connection used to call any socket that
opened a good one, so a relay that let sockets in and dropped them was retried within a second, for ever, by
every phone (thirty phones, about forty backend calls a second). A connection now counts as good, and its
waits start over, only once its socket has stayed open ten seconds (live/connection.ts
STEADY_MS): until then the waits keep doubling to a minute. A pin: a snapshot
speaks of other people and nothing proves who made it, so a tampered phone could say a friend's counter was
the highest number there is, and that friend's next move had no number left to take. A snapshot's counter is
now believed no higher than 10,000 (places.ts TOLD_MOST), while a person's own move
may be numbered far past it: a made-up snapshot can put a friend somewhere once, and their next tap moves
them on every phone. Ten thousand is more moves than anybody makes in a night, and far enough under the
highest whole number that counting on from it never runs out. What it costs: somebody whose own count is
past ten thousand is told to a late phone at ten thousand, in the right place.
The room has one plan: one game, a tournament of one game, or a party, and a pick on any
tab of the sheet replaces the others. Which kind is in hand rides the room's one mirror of the host's picks
(pendingSettings.kind, beside the game and the party's running order), so a guest's bar and sheet
show what the host's do. The server passes it through like the rest of the mirror: no backend change.
Why: Karim picked the room as a place to play in and then approved it as built ("Everything looks good. Let's start the real build. Finalize this approach."): the calls are in "The room is a place to play in", "The games sheet" and the one-phone card beside them, and are not repeated here. Scenes as data is what lets a second scene be one folder, and later scenes be something a host chooses. Walking about is play, not a fact about the room: nothing depends on it, it happens many times a minute with thirty people in, and it is worth nothing tomorrow. A write per tap would be a database write on the busiest path in the app for the one thing that never needs keeping, and would wake every phone watching the room each time (the rules in the root card, "Write it spike-safe the first time").
Rejected: replacing the living room with the new scenes (it is the room people know, and
it was approved as a scene alongside the pool). Storing every move as a database write, or seats as a field
on the roster the way the old room's players.sit does it (cost and wake-ups, above; and a
server's answer is not needed for something with no winner). A Vote button, or a Pick button: a tap is the
whole act. Keeping the TV as the picked game (the bar and the sheet say it, and the scene gets the screen).
A room for one shared phone. And, found in review the same day, letting a phone hold a place for somebody
else (keeping the people who never moved where it last drew them when a friend left, and choosing a
newcomer's landing place for them): each phone made those up from what it happened to have heard by then,
the roster and the moves reach every phone woven together differently, and two phones ended up settling one
spot two ways for good. On the wire: every phone answering a hello (twenty-nine snapshots for one
newcomer); a number on each hello for answers to quote (the relay passes messages on in one order to
everybody, so a snapshot heard after a question reached whoever asked, and the number had no job); every
phone picking "the best free seat" as its first move even when nobody could tell it the room (after a game
thirty phones open the room together, see the same free seat and take it, and all but one are sent to the
crowd); and deriving on a guest's phone whether the host means a tournament (a two-player game with three in
the room was a tournament in the old room, which left no way to play one match of it without a detour).
What would reverse it: phones that visibly disagree about who is where in real rooms, which no rule fixes and a stored answer would. Where someone stands coming to MATTER (a game that reads it, a seat that should still be yours next week in a room that is kept): then it is a fact, and facts are stored. The live channel proving too costly or too unreliable to carry it. For the sheet: guests who do not find that a tap is a vote, once real friends use it.
Decision: building a party is ~/ui/party/PartyBuilder, which does not know
what it stands in. The party's own sheet, PartySheet, is only its frame: the sheet (a phone's
bottom sheet, a big screen's dialog), the title, and the party's own foot (PartyFoot) pinned
under it. Any other place a party is built mounts the same builder. Three things make that possible.
(1) One hook holds every answer. The host calls usePartyBuilder once, hands the
result to the builder, and reads its own foot off the same result (how many games, about how long, how many
more are needed, whether it can start). The sums stay in partyPlan.ts
(partyBuildPlan, addGame, randomLineup). (2) The line-up is
shared, the other games are a slot. PartyLineup (the numbered rows, the drag, taking a
game out, the empty places, Random) is the same component for every host. The list of games not in yet is a
slot (more) with the sheet's tick rows as what stands there by default; so are a section's title
(heading) and where Random rests once games are in (randomHere, for a host with no
party foot). (3) It has no scroller of its own and a read-only form: with no
onChange the same rows are drawn with no grips, no way out for a game and no Random, titled with
whose party it is.
An empty party is drawn, not described. Places 1, 2 and 3 stand empty in the shape a game will take (as many as the room's party needs: fewer where fewer games fit, none where none do), a game ticked fills the first of them, and Random is the wide button right under them. Once a game is in, Random steps back to the foot. "Nothing yet. Tick a game." is gone; "pick 2 more" is said once, beside Start. While the party is empty the foot has Start alone, so one screen has one Random.
Why: the room's games sheet gets a Party tab, and its preview drew the line-up a second time because the builder was a whole sheet that kept its rows to itself. That is the 2026-08-17 failure waiting to happen again (the same screen built twice, the second copy shipping bugs the first had fixed), and the house rule forbids it: one product, one builder. The empty places are Karim's, 2026-10-09 to 12, on the preview: "for the party, it is looking very ugly: the empty state when the games are not selected. There also, we need something visual instead of text." They are built into the shared line-up, so the one-phone sheet and the room's host get them as well.
Rejected: a second builder inside the room's sheet, kept in step with this one by hand
(the sentence "the same as the party sheet but…" is the bug). One component that asks where it is
(if (inRoom)): the hosts differ in three named places, and three props say so. Handing the foot
the numbers through a callback after the builder has drawn: the foot is pinned outside the builder's own
tree in both hosts, and a second derivation is a second copy. Letting each host draw the whole line-up and
share only the sums: the rows, the drag and the empty places are exactly what drifted in the preview.
Keeping Random in the foot under an empty party as well as under the places: two buttons for one thing.
What would reverse it: a host whose line-up must truly look different (not its other games, its line-up), which would mean the line-up is not one thing after all. Tables that miss Random in the foot of an empty party, or that read three empty places as "you must pick three" when they wanted Random.
Decision: the one-phone set-up is "Next up" (modes/local/setup/Setup): the
friend about to join waits as a mascot in the colour they will wear, their name is typed on the tag under
their feet, and they join a crowd of whole mascots (Stage, Crowd, Dock;
how big the crowd stands and how its rows fall is crowdFit). The other two drawings, the switch
between them and the address's design are deleted. The name box's placeholder is "Player name",
under a key of its own, and the "Add a friend" box in the room between a one-phone party's games says the same. On the party's build sheet (~/ui/party/PartySheet, one sheet for a room
and for one phone) the Shuffle button is Random: a press replaces tonight's line-up with a fresh pick in a
random order (partyPlan randomLineup, a pure rule with the dice passed in). It picks
only games on offer, so only ones everyone here fits; as many as a party asks for before it can start (three),
or as many as the night already has when that is more; never more than a night holds; and never the
line-up it was given while another exists. Putting the same games in another order is no longer a button:
dragging does that.
Why: Karim, 2026-10-09, on the three drawings: "I like the first idea. Writing the mascot name and the mascot coming in the center of the screen is all good. It's clean, and that's creative. That's what I'm talking about." On the placeholder: "Their name doesn't make sense here. This is actually literally system text getting into the final user-facing output... It should be either 'player name' or 'enter the name.'" On the sheet: "Shuffling is not a big deal. Selecting which games to play at the party is a big deal. If the player group can't decide on games, they can just click on random... Changing the order is not the problem anytime. Selection is the problem." The sheet opens with nothing ticked, so there is no default line-up to copy a count from; the one number it proposes is how many a party needs, and Random uses that.
Rejected: the sign-up sheet on a clip (each name in its own colour's ink, the coral keeping the list) and the games laid out on the screen with names as tags, the two drawings he did not pick. Keeping Shuffle beside Random (two buttons, and one of them answers a question nobody has). A Random that only adds to what is ticked (the table that cannot decide has ticked nothing worth keeping). A fixed count of four or five (it would overrule a host who had built a short night on purpose). Leaving "Their name" on the room's own "Add a friend" box because "their" has a friend to point at there: he extended the ruling to it, so both boxes read "Player name".
What would reverse it: tables that tap Random and then always add a game by hand would say three is too few. A group that wants the same games in a new order often enough to miss a button for it.
Decision: the one-phone set-up keeps its job and its rules (the entry below) and loses its
look. modes/local/SetupScreen still decides everything and now hands one set of props
(setup/types.ts) to a drawing; three drawings live side by side under
setup/designs/, picked by the address (/local?design=1, 2,
3) with a development-only switch that keeps the names typed. Each is a different idea, not a
skin: the next friend waiting as a mascot over a crowd of whole mascots; a sign-up sheet on a clip with each
name in its own colour's ink; the games on the screen as moving pictures with the names as tags. When Karim
picks, the other two folders and the switch are deleted. Taking a friend off the table no longer asks:
the name goes at once and a toast offers Undo for five seconds, which puts them back in the same place and
colour under the same name (useTable's restore). What a game had set for each
player, and who was picked to sit, are the table's as it stands now: they come back with the usual answers.
An Undo the table can no longer honour (it has filled up, or the name has been typed again) says why, and
the toast leaves with the screen.
Why: Karim, 2026-10-09, on the list: "I absolutely hate the current design of the single device. It is looking so bad, so ugly. No personality or creativity at all... It's looking very basic." And on removing: "Removing a player is just removing a name. You can re-add it... show 'player removed' and an undo option." The list did its job and looked like nobody's product: small heads in rows, and the game as a blank white row with an arrow, on a screen whose app draws every game as a moving picture. He asked for three ideas to play with rather than one to approve, because the answer is taste. Undo is the app's own answer for a quick slip (Keep out at the door, 2026-09-14): a question before every removal costs every removal a tap to protect the rare wrong one.
Rejected: restyling the list (the same rows in nicer boxes are still a contacts list); bringing the living room back to one phone (he ruled it out the same day, below); three pictures to compare instead of three builds (he judges by playing); keeping "Remove Sam?" with a friendlier line; an Undo that adds the name again at the end of the table in a new colour (that is typing it again, not taking the slip back).
What would reverse it: his pick ends the three-way switch. A table whose per-friend settings grow heavy enough that losing them to a slip really costs something would bring a question back for that case only.
Decision: the one-phone set-up screen is a plain list: "Who's playing?", a name field
(Return adds and the cursor stays), one row per friend (their head in their own colour, the name, a ×), one
card for the game, a row for a party, and one yellow Start. modes/local/setup/SetupList draws it
and modes/local/SetupScreen decides. The living room (~/ui/room) is the online lobby's
scene, and the room both shells draw between two games of a party. This ends "one scene for the online room
and the shared phone" (2026-10-02, below) for the set-up screen. What the two screens still share is everything
they decide: the seat rules (modes/setupRules, useSeatPicks), what Start says and asks
first (modes/startSheet, ui/StartSheet), the game picker, the party's sheet and its
sums, and the words of a party's running order (useRunningOrderShow).
Why: Karim, 2026-10-09: "On a shared phone, we don't need this live room. We just need a static list, like how it was there initially, which should be enough. We don't care about the room or how it looks. The only thing we care about is adding the names of the participants quickly and getting started. For a shared room, keep it simple: no need for the room. A simple list should be enough." The lobby is about to become a new scene (a playground, a pool party). A scene earns its place where people arrive one at a time on their own phones and have something to do while they wait: knock, vote, pick a place. On one phone one person types everybody in, and a scene puts taps between them and each name. Both screens only draw, so splitting how they look splits nothing they decide.
Calls made in the build (for Karim's review):
useTable).What we rejected: following the new playground scene on one phone: his words above. Keeping the living room on one phone while the lobby moves on: two scenes to keep, and the slow way to add a name stays. A second copy of the seat rules for the list: that is the 2026-08-17 failure (learnings), and nothing about a list needs it. A row of remembered names to tap in: the table already opens on the names it had last, so those names are the rows. Remembering people who are not at the table is new saved state, and nobody has asked for it yet.
What would reverse it: one phone becoming something the table looks at together before a game (cast to a TV, say), where a scene has an audience. Or names no longer being typed: friends remembered across nights and tapped in would make this a picker, and it may want another shape. If the one-phone party's between-games room goes as well, the room's one-phone paths (the "Add a friend" seat, bunting for the code) have no caller and go with it.
Decision: what someone with no seat in a room can ask in a loop has a limit, and so does what an account can (rooms, starts, question sets). Everything a phone sends is weighed before it is stored, and an account that attacks the service can be blocked. What a player SEATED in a room can repeat (moves, lobby taps, fan picks) has no limit yet; see "Not built yet". Karim, 2026-10-08: "There are two types of cheating or attacks. One is cheating in the game. Another is some hackers trying to attack from outside, take it down, spam it, or do brute-force attacks. These kinds of things, we should take utmost care." And: "What can a free user do? What can a paid user do? These things we should define first, clearly."
Two kinds of limit, kept apart in one file, convex/lib/limits.ts.
PLAN_ALLOWS in that file has a row for each plan (free,
paid) and today allows every plan the ceiling, so nobody can meet a plan limit.
planOf says which plan an account is on: everyone is free, because there is no payment system.
Setting a plan limit is writing its number in that table and adding the line for its reason
(PLAN_PACKS_A_DAY, PLAN_ROOMS_AN_HOUR) to the app's words. A plan's number is
refused with that reason and the ceiling with the safety one, so a screen can tell "that is the free plan"
from "slow down".| What | Number | Counted per | Why that number | The player reads |
|---|---|---|---|---|
| Question sets a model writes | 60 a day | account (a plan's number; the ceiling today) | A host makes ten or fifteen in a long night. Caps what one account can cost. | "That's plenty for one day. More tomorrow." |
| Question sets, everybody together | 5,000 a day | nobody: one count, across eight rows | Bounds a day's bill for the paid model. It can trip up to 5% early, never late. | "We've run out for today. More tomorrow." |
| Rooms opened | 20 an hour, one back every three minutes | account (a plan's number; the ceiling today) | A host opens a handful in a day. Stops open, close, open. | "That's a lot of rooms. Give it a few minutes." |
| Matches started (a match, a rematch, a tournament, a party) | 20 a minute, one back every three seconds | room | The quickest real game is played again six times a minute. | "One second, then start again." |
| New knocks at one door | 30 a minute, one back every two seconds | room | A full room arriving together all wait. Stops "stop waiting, knock" in a loop. | "The waiting line is full right now. Try again in a few minutes." |
| Writes of the same knock | one a minute, unless the name changed | knock | A knock stands ten minutes. A sooner repeat succeeds and writes nothing. | nothing |
| Writes of a player's "I am here" | one in 10 seconds | player | A phone says it every fifteen. A sooner one succeeds and writes nothing. | nothing |
| A host's settings, and the lobby's mirror of them | 4,096 bytes | call | The heaviest real sheet (Tambola, thirty players) is 1,348. | the ordinary refusal |
| One move | 32,768 bytes | call | The largest is a whole Doodle Twist drawing, under 12,500. | the ordinary refusal |
| A host's own list | 65,536 bytes, and only the fields an item is made of | call | A full list of any game's heaviest real item is under half. | "That's too much to fit. Trim it a little." |
| The id a device goes by | 8 to 64 characters | call | The app mints 21. | nothing: it holds no seat |
This reverses a call of 2026-08-16. That day Karim removed the caps on question sets (three a room, twenty a host a day): a set costs a fraction of a cent, and a host hit a wall mid-party for no reason they could see. A per-host number is back, at sixty a day. It is a different thing from the one removed. The old cap was a product limit, set where a keen host met it. This one is a ceiling against an account asking in a loop, three times the old number and far above real use, and it exists because a signed-in script was the one way to run up a bill with nothing in the way. What a host is ALLOWED to make, as a matter of product, is no longer this file's business: it belongs to the plan table, below.
The two daily counts start again at midnight UTC, the same day the Convex dashboard counts. A start
also clears the room's own finished match away: a match row is read by the winner screen and the play-again
sheet until the room starts something else, and they used to stay for as long as the room lived. And an
account can be blocked (users.block, run from the dashboard): it marks the account and closes
its open room, and every door a host comes through refuses it ("This account can't host right now.").
Why no limit is per network address. A Convex function is never told the caller's address, so "ten a minute per visitor" cannot be built on this backend. The security audit's plan to limit room-code lookups per address was written before that was checked. A guest's session id is made up by the phone, so a limit on it is passed by making up another. What the server does know is the signed-in account, the room, and everybody together, and every limit here counts one of those. A refused call is still a call we are billed for; a limit stops what the call would have done (the row, the thirty screens woken, the paid model). The brake on the bill itself is in the Convex dashboard and is the owner's to set: releasing, "Limits the owner sets".
How they are counted. The @convex-dev/rate-limiter component, the one new
dependency (0.3.x, which fits the Convex this repo is on). It counts inside the transaction of the mutation
that asks, so a mutation that fails gives its turn back and two requests cannot both take the last one.
Measured on the dev backend: a mutation that asks a limit is ONE function execution in the log, with no
separate one for the component; it reads about 385 bytes and writes 269 more than one that does not, and
took about 33 ms where a bare one took 4. (The log, not the bill: the usage page was not read.)
How exact the daily numbers are. The component can only clear its rows by age, so the clearing drops a count that is still in use, about once a week for each. For a daily count that would be a second day's worth, so the job runs at midnight UTC, the moment the daily counts start again: what it gives back is the few seconds since midnight. A minute's or an hour's count dropped mid-use starts again full once a week (twenty more rooms, twenty more starts). And any limit lets through up to twice its number across the moment it refills: sixty before midnight and sixty after.
What we rejected. A bot check at the door (Cloudflare Turnstile): it runs in a browser, so the native app would need a web view for it. Longer room codes: four characters is Karim's call of 2026-08-14, and a swept code still only buys a knock. A breaker on knocks for everybody: an attacker could trip it and lock every door. Limiting the public room lookup: a query cannot take from a limit, and a miss already reads nothing. Counting question sets from the usage table: a hand-rolled count is what the component exists to replace. Deleting a finished match the moment it ends: its winner screen is still open.
Not built yet, on purpose. Loops still open to a player who holds a seat: moves in a match (each accepted move goes to every screen in the room), lobby taps (picking a seat, voting for a game) and fan picks in a tournament. A seat takes a host's tap to get, and the host's account can be blocked, but none of the three is counted. A limit on moves is to be measured first, because it sits on the busiest path there is. Also not built: a switch for the host to close the door, the roster for members only, and clearing abandoned sign-ins.
The plans, as the owner set them on 2026-10-08. NOT enforced. These are the numbers
PLAN_ALLOWS is waiting for. None of them is in the code, and none will be until there is a way
to pay: with no payment system every account is free, and a free limit with no way past it is only a wall.
| What | Free | Paid |
|---|---|---|
| Rooms | Unlimited. Sees ads. | Unlimited. A paying host clears the ads for the whole room. |
| Permanent rooms | None | Up to 10 |
| AI question sets | 10 in total, to try it | By plan. Floated, not settled: 100 on a weekly plan, 500 on a monthly one. |
| Tournaments and party nights | 1 of each | Unlimited |
Behind all of it sits a hidden safety cap: the ceilings in the table above, which hold for every plan. Two of these are not counts per day or hour (ten sets in total, one tournament ever), so when they are built the plan table gains a kind of row it does not have yet. Ads and permanent rooms are features, not limits, and are not this file's.
What would reverse it. Convex telling a function the caller's address would make
per-visitor limits possible and the right tool for the door. A real host reaching a safety number means the
number is wrong: raise it, do not explain it. The owner deciding the plans turns PLAN_ALLOWS
from one number per row into the product's own.
Decision: a game whose content is a secret has two lists that share no item. A room is
dealt from one that exists on the server and nowhere else. A phone that runs a match itself (one shared
phone, a hotspot host) deals from the other, which ships in the app. The rules file holds neither: whoever
runs the rules hands setup its list as ctx.deck, and a game handed none refuses to
start. Four games play this way: Trivia Clash, Gibberish, Undercover and Quick Draw. Karim, 2026-10-08: "No
cheating is allowed. No one should be able to know anything using any tools."
Why: a game's rules imported its content and its screens import its rules, so every answer list was inside the app. A phone has to be sent the question to draw it, and a script reading what the phone received looked the answer up. No view can fix that: the list itself has to be somewhere a phone cannot reach. Every game's view was read for this, and for anything a phone is sent that names a secret:
| Game | What a phone was sent | Found |
|---|---|---|
| Charades | The room's memory of dealt prompts, and a host's whole list | A leak with no list needed: the last entry is the prompt on the table. Fixed in the view the same day (learnings). |
| Trivia Clash | The question being asked, with its id | The answer was in the app's list. Rooms are dealt from the server's list. A match keeps its ten questions. |
| Gibberish | The scrambled card | The phrase was in the app's list. Rooms are dealt from the server's list. A match keeps the cards it plays, whole. |
| Undercover | Your own word | The other word of the pair was in the app's list. Rooms are dealt from the server's list. A match keeps a hand: one pair for each game, and spare pairs for Mr White's decoys. |
| Quick Draw | The word's length and its hint letters | Those picked the word out of the app's list. Rooms are dealt from the server's list. A match keeps a bag: three words for every turn. A guesser is still sent the length and the letters, which is the game. |
| Next Letter, Word Search | An open board | No secret. A solver plays them perfectly with any dictionary, which no list can stop. |
| Every other game | Only what its screen shows | Clean. |
How it is held in place. Four things, each on its own level.
setup can be handed a list: its context is a type of its
own, and the one every other rules call takes has no place for a list.*.server.ts, so a careless import stops the build instead of shipping the answers.What we rejected:
pack setting. But a pack means "the host made this": the room is
marked as playing its own content, the pack is saved with the match and replayed on a rematch, a quiz
plays it whole and in the host's order, and a party's games do not pass that way at all. The deck is not a
setting, so there is also no field a tampered phone could fill in.setup can see
the list, so the whole match is dealt at the start. A word a drawer turns down is not offered again in
that match, and is not remembered, so a later match can deal it.What it costs. Each list is shorter than the one it came from. Two thirds of every shelf went to rooms. Matches before something comes round again, at the settings a game opens with:
| List | Before | Rooms | One phone |
|---|---|---|---|
| Trivia Clash, 10 questions a match | 227: 22 matches | 150: 15 | 77: 7 |
| Gibberish Mixed, 12 cards a match | 266: 22 | 178: 14 | 88: 7 |
| Gibberish Desi, 12 cards a match | 106: 8 | 71: 5 | 35: 2 (4 at eight cards) |
| Undercover, 3 games a match | 300: 100 | 200: 66 | 100: 33 |
| Quick Draw, 4 players and 2 rounds (8 words drawn) | 900: 37 to 112 | 600: 25 to 75 | 300: 12 to 37 |
Quick Draw's range is whether a table always draws the same level or spreads its picks. A match holds what it was dealt, not the list: about a kilobyte more than before for Gibberish, Undercover and Quick Draw at their usual settings, nothing more for Trivia. No call and no read is added to what the backend is billed for. The real answer to every number in the table is more content.
What it achieves today, and what it does not. The split is a move of rows that every build of the app made before this change already holds. All four room lists are made entirely of such rows: Trivia's 150 questions, Gibberish's 249 cards, Undercover's 200 pairs and Quick Draw's 600 words. Any copy of an older build has every one of them with its answer: the site as it is served today, an app installed before this ships, and this repository's history. So "a phone never holds an answer before its moment" is true of the CODE from this change on, and it is not yet true of the CONTENT. It becomes true of a list only once that list's room rows are rows that have never shipped. Until then a player who kept an old copy can look a room's answers up exactly as before. Writing those new rows is a separate job, and it is the one that makes the claim true.
What it never does. It does not make a finite list unknowable. Somebody who plays through a whole room list has seen it, the way a regular at a pub quiz has, and a room that never repeats shows it in the fewest matches. What changes, once the rows are new, is the price: one download of the app used to be every answer, with no trace. On a hotspot the guests hold phones of their own and their copy of the app carries the one-phone list, which waits with that mode (the pause page).
An item never changes sides. After this first split, a row stays in the list it is in. The lists only grow, each on its own side. Neither direction is free:
bun run split:decks made the first split and is a one-time tool for that. Run again with the
same share it changes nothing. Run with another share it moves rows across the line, says how many and which
way, and both of the costs above apply. The share itself (two thirds to rooms) is still Karim's to confirm,
and the time to change it is before any of this ships.
What would reverse it: nothing brings an answer list back into the app. The split could go: if every room played content made fresh for it, a room list would have nothing left to protect and one-phone play could have every item.
Decision (Karim: "is it safe to fix it? If yes, do it"): a Board's dispatch takes
an optional second word about the send, { automatic: true }, meaning the player did not make this
send at this moment: the Board made it for them. When the rules refuse such a send, no shell shows the
refusal line. Two sends carry the mark: Hide and Seek saying where a hider is just before the bell, and
Charades opening a turn the moment it becomes the actor's. Every tap is answered exactly as before, those
games' own buttons included ("Hide here", the turn's button). Doodle Twist's drawing, handed in when time is
up, is NOT marked: a drawing that arrives too late is missing from the gallery, and its player needs to hear
"Too late for this one" (found in review, 2026-10-08).
Why: a send the phone makes by itself can cross a change of beat while it is in the air (the last hider presses "Hide here" in the same instant, a phone is a second slow). The rules then refuse it, rightly, and their own answer stands. The shell said "That move won't work here" to somebody who had made no move. This is the case the tapped-spot entry below left open.
Where it lives: the mark is one optional field on the Board's contract in the engine
(SendNote). It names what the send is, never what a shell should do, and it never reaches a
reducer: no rule allows or refuses anything differently. The three shells hear a refusal in three different
places (online a promise that rejects, one phone a throw from its store, the hotspot host answering a
guest's socket), so each asks one shared question at its own:
apps/app/src/modes/automaticSend.ts. A hotspot guest's mark rides on its move to the host, and
the host sends no refusal back, because the refusal a guest receives names no send.
What still shows, as loudly as today: anything that is not the rules' own answer. Out of the room, a room that is gone, no connection, a server failure, a fault in the rules. A refusal is told by its code, the five a reducer can throw. Three other answers share one of those codes, and an automatic send that meets them also goes unsaid: a room with no game running, a match the host has paused, and a sender who has no seat. Each is the same crossing (the match ended, stopped or changed in the air).
Rejected: a Board catching the refusal itself: it cannot, dispatch hands the
move to the shell and returns nothing, and three shells answering three ways would have to be taught to
answer a Board. Swallowing every refusal of every send, or every refusal of these games: a tap that does
nothing and says nothing is worse than the line. Telling a guest's phone to hide "the next refusal" after an
automatic send: that is a guess about timing, and the wrong toast would vanish. Naming the mark for the
effect ("silent", "no toast"): a Board would then be steering a shell it must not know about.
What it costs: a real fault in a marked send's rules no longer shows a line by itself. Each of the two still has a tap that does the same thing and is answered, and while developing the shell warns in the console when it keeps a refusal to itself. The hotspot's frozen guest pages are from before the mark, so a browser guest is told as before until they are rebuilt (the hotspot mode is paused).
What would reverse it: a Board marking a send the player did make, to quiet a refusal it should have prevented: then the mark is being used for its effect and should go back to these three by review. Or a marked send whose refusal the player needs to hear and has no tap for: that send gets its own screen for the outcome, never the toast back.
Decision: the backend is asked only what a screen is about to draw, and wakes only for what still matters. Five changes, from one study of what the dev backend was being paid to do:
| What | Before | Now |
|---|---|---|
The who-is-here list (presence.list) | Watched by every phone, in every state of the room | Watched only while the room's seats are on the screen: the lobby, and a party's screens between two games. Anywhere else everyone reads as here |
| A phone's check-in | Every 15 seconds, always | Every 15 seconds while the seats are on screen, every 30 otherwise, and at once when they come or go |
| Which game a room is playing | Its own subscription (engine.activeGame) | Read off the room every phone already watches. The function is deleted |
| "Your room is still going", on the home | Asked by every visitor | Asked only when this device remembers a room, or somebody is signed in |
| A match's scheduled wake-up | An out-of-date one was left to wake, read the match, see it was not wanted and go back to sleep | The match remembers the job that is waiting, and the write that makes it pointless cancels it |
Why: measured on the dev backend from 1 to 8 October: 164,000 function calls, of which the who-is-here list was 62,000 and the check-in 17,000. Nearly half of everything, for one faded seat. A question a phone keeps open is paid for once per phone every time it is answered again, and every check-in answers the list again for every phone holding it. So one check-in in a room of N costs 1 + N calls. Eight phones in a game came to 288 calls a minute (four check-ins each, nine calls apiece) to keep a picture up to date that no screen was showing: the only thing that draws who is here is a seat of the living room, and the living room is on screen in the lobby and between two games of a party. The same eight now cost 16 a minute. Measured after the change, three phones in a game for two minutes: four check-ins each, thirty seconds apart, and the list on no phone.
Keyed on what is true, not on what happens to coincide. The first version watched the
list while the room's status was "lobby". That is where the seats usually are, and not what they
are: a party is "playing" all night and draws the same living room between its games ("next up", and
"nothing fits"). So a guest whose phone had died during a party game was drawn as present, on the very
screen where the host can remove them. A review caught it before it shipped. Now the living room says for
itself when it is on screen (ui/room/SeatsOnScreen.tsx), and the online shell watches the list,
and checks in quickly, for exactly that long. On screen means its screen is the one in front, not merely
mounted: a living room left under something opened over it (the one-phone set-up screen under an invite
link) would otherwise keep that phone watching through the whole game above it. No screen has to remember
to ask, and the next one that draws the room is counted the day it is written. A tournament's screens draw
no seats (its bracket and its boards fade nobody), so nothing is watched there. Measured with three phones
in a party, one of them closed during the first game: on "next up" its seat was drawn faded on both phones
that were left, and the list was on no phone during either game.
Why 30 seconds and not 60: the server calls a player present for 45 seconds after a check-in. One that is 30 seconds old is still inside that when a game ends and the seats come back, so nobody fades for a moment after every game. The clean-up of silent rooms wants one check-in in thirty minutes, and is untouched.
The other three are the same thought. A phone was holding a second question open to learn which game its room is playing, which the room it already watches tells it. The home is the first page every visitor opens (and where a video sends ten thousand of them), and each one asked about rooms it had never been in. And a game with a turn clock booked a wake-up on every move and never took the old one back, so each move was paid for twice: once when it was made, once when its stale wake-up fired.
How the cancelling is kept safe: the old guards stay exactly as they were, as the net
underneath: a wake-up nobody cancelled still finds it is not wanted and does nothing. The job's id rides in
the write the move already makes, so a move still writes one document. And a job never cancels
itself. The job a match remembers may be the very one that is running (a tick booking the next
tick), and Convex drops whatever a cancelled running job has scheduled: a tick that cancelled itself would
stop a caller, or a turn clock, for good. So every function that books or cancels is told who is writing.
When it is the match's own tick or flag, past its own guard, the job the row remembers is by definition the
one running: it is replaced in the row's memory or forgotten, never cancelled. Anyone else (a move, a pause,
a resume, a removal, an ending) cancels the job the row remembers, because that one is some other job, still
waiting. Under that sits a second net: a job is cancelled only while it still reads as waiting. The first
version had only that net, and it holds only if a running mutation reads its own job as "in progress",
which Convex documents for actions. The rules are at the top of convex/lib/matchTimers.ts, and
convex/engine.pendingJobs.test.ts holds them, including by running a job's own function by
hand while its job still reads as waiting.
Rejected: moving who-is-here onto the live relay now. The relay closes a quiet connection by itself after three minutes, which fights a signal whose whole job is to say "still here" while nothing is happening, and the clean-up of silent rooms reads these same check-ins: lose them and a dead room is never removed. A slower check-in in the lobby: it changes what players see (a seat that takes longer to fade) to save a little on the shortest part of the night. A 60-second check-in during a game: everyone fades for a moment each time the seats come back. Watching by the room's status: see above. Leaving the stale wake-ups to the guards: correct, and one paid call per move for nothing.
What it costs: a build of the app from before this change still asks which game a room is playing, and the backend no longer answers, so it cannot open a room's game. Nobody outside the project holds such a build. The backend and the app go out together.
What would reverse it: a screen that draws who is here some other way than the living room's seats, during a game (a dropped player marked on a board, or in the Players sheet). Then the list is watched there too, and should first be rebuilt so a check-in does not answer it again for everyone. The relay growing a "still here" signal the room clean-up can trust. Or the cancelling proving more trouble than the calls it saves: the two fields and one file go, and the guards that never left carry on alone.
Decision (Karim): a hider is sent, and shown, where every other hider has hidden: each in their spot, as their own mascot in their seat colour, while everyone hides and while the seekers are out. A hider who has been found goes on seeing them. A seeker, and anyone watching without a seat, is sent exactly what they were sent before: only people already found. On one phone the hider holding it sees the hiders who went before, and once the phone is in the open nobody is sent a hidden place. Another hider appears once they are in, not while they are still choosing.
Why: his words: "I expected all the hiders will be visible to everyone. Only the seekers don't see anything... if I'm a hider... I see other people, other hiders also in the room." It is how the game is played in a house: the people hiding know who went where.
Rejected: keeping hiders blind to each other, which is what was built (a hider was sent only their own place). Showing each other moving about live, now: that belongs to a separate roaming feature and comes later.
The consequence he accepted: the first person found knows where everyone is and can say it on the call, as in the real game.
What a seeker is sent did not change by one byte. A rules test builds, from the full state, the whole view of somebody who must not know (every public field, and only the found, in the order they were found) and compares it with what a seeker and a watcher are really sent as text, at every beat of whole games in every place, in a room and on one phone, with each seat leaving and after the clock has hidden people. The same test passed on the rules as they were before this change.
What would reverse it: rooms where the found give the others away so often that seeking stops being a game. The smallest step back is that a hider stops seeing the others once they are found.
Decision (Karim): in a room, a hider who has tapped a spot and never pressed "Hide here" is hidden in that spot when the hiding ends. About four seconds before the bell their phone says where they are. The seats send a breath apart (thirty seats over 2.3 seconds), so a full room does not write to the match in one instant, and nothing is sent in the last second, when it could land late and be refused. A tap somewhere else after that is sent once more. A hider who pressed the button and then tapped somewhere else is moved the same way. One whose spot the room already has sends nothing. At the bell, chance still hides whoever the server heard nothing from, which is somebody who tapped nothing, or whose phone was asleep or offline. One phone has no clock, so none of this happens there.
What the phone sends says where the player is, and never that they are in. It is its own message in the rules (a "tuck"): it writes the player's place and nothing else, for a hider in a room while the hiding is on. So it can never end the hiding. Only every hider pressing "Hide here" does that, or the bell. The first build sent an ordinary "Hide here", which also says the player is in. When the phone sending was the last one missing, the seekers set off up to four seconds before the bell, and a phone sending just behind it was refused: its player saw "That move won't work here" for a tap they had made in time, and stayed in their old spot, against the ruling. Now, whenever somebody has only tapped, the hiding runs to the bell and every phone's word lands inside it. (Found by the second code review, before merge.)
Why: his words: "A hider taps a spot but never presses 'Hide here'. Time runs out. Where are they? In the spot they tapped." Their phone had ringed the spot and tucked their own mascot into it. And for the other half: "If someone hasn't tapped any spot and the timer runs out, the system should hide them in some random spot."
Rejected: sending every tap (each one is a write that wakes every phone in the room: thirty people trying spots would triple what a game costs). A short beat after the bell that still takes spots, the way Doodle Twist collects drawings (an extra write and a wake for every phone, every round).
What would reverse it: rooms on slow connections where the send lands after the bell often enough to be noticed. The send is timed by the phone's own clock against the room's deadline, and never later than a whole hide after the phone heard the hiding start. So a phone whose clock is far out may send early, which is harmless, or not at all, which is chance as before. Then the margin grows, or the rules get the beat after the bell after all. Verified by reading and by the gates only: it needs one played check in a room.
Left open: a send that crosses the end of the hiding in the air is still refused (the last hider presses "Hide here" in the same instant, or a phone is more than a second slow), and the play shell says every refusal out loud. A board hands its message to the shell and hears nothing back, so it cannot send quietly. Making that refusal silent is a change to the shells, for every game, and was not made here.
Decision (a code review's finding, for Karim to confirm): a round holds 4, 8, 12 or 16 looks. Sixteen is two seekers in the biggest place (17 to 30 players), and it now plays 2, 3 or 4 rounds for Short, Normal and Long. The other three rows are as they were: 6, 8, 10 for four looks, 5, 6, 8 for eight, 4, 5, 6 for twelve.
Why: the table had no step above twelve looks. Sixteen looks at five rounds is about 15 minutes at six seconds a look, and 27 with every clock run out. At three rounds it is 9 and 16.
Rejected: 3, 3, 4 (Short and Normal the same: a setting that does nothing). Four rounds at Normal (12 minutes, but 21 with every clock run out).
Left open: twelve looks at five rounds is 12 minutes and 21 with every clock run out, and that is the usual game at 17 to 30 players (three seekers). Four rounds would make it 9 and 17. Twelve looks is also a table of 12 to 16 that picks three seekers, so the row was not touched without his word.
What would reverse it: big tables finding a Short game of two rounds too little, since only four people get to seek in it.
Decision (Karim asked for the big group and for the host to choose the seekers. How it is done is the orchestrator's, for him to confirm): every place is three sections of nine spots. The seats at the start decide how many are open, one for 3 to 8 players, two for 9 to 16, three for 17 to 30, and that never changes mid-game. The place scrolls, with a strip of its stops over it, and a watcher's screen follows whoever is looking. The host picks the seekers: what fits the table (one a section), or 1, 2 or 3. A round is four sweeps, each seeker getting the sections divided by the seekers (rounded up) in looks a sweep, one look at a time in turn. The host picks a length, not a number of rounds, and the rounds come from how many looks a round holds. Hiders score for the sweeps they last. The seekers score together for the share they find.
Why: Karim watched the game and said the train "looks small", asked for "a bigger train with multiple compartments so we can scroll", the same for the other places, and "at least a 20, 30-person group". Thirty players in nine spots is three to a spot and found in a handful of looks, so the spots have to grow with the players. He then asked that the host may keep "only a single seeker so that they keep the excitement going", so the looks have to keep pace with the place whoever does the looking, or one seeker in twenty-seven spots finds nobody. One look at a time keeps every screen on one story. Rounds worked out from the looks keep a big table's evening the length of a small one's (another game shipped a draft that week where the length followed the table, and two players finished in three minutes). The seekers score as a team because a review of the first build showed the seat that seeks least winning twice as often: seeking paid about a third of what hiding did.
Rejected: one place of a fixed size for every table (empty for four, a crush for thirty). All the seekers looking at once (several walks to watch, and nobody sees a find that is not on their screen). A size the host must pick (one more question on the start sheet, and a wrong answer spoils the game either way: the table already says how big it is). A seeker scoring 1 for each of their own finds (it underpaid seeking, and with several seekers it paid whoever happened to open the crowded spot).
What would reverse it: tables that find a scrolling place hard to hold in their heads, which would mean fewer, bigger spots a section before a smaller place. Seekers who want their own score, which the team's shared points take away. And on one phone a round's length still grows with the table (29 hand-offs at thirty seats): if big groups really play on one phone, that needs its own answer.
Decision (Karim, from a mock): a Hide and Seek game is played in one place seen from above, as a floor plan: walls with thickness, furniture with hard little shadows, and the nine hiding spots are the furniture. The seeker walks the corridors with a torch. There are four places (the flat, the school, the wedding hall, the night train). The host picks one, or leaves the start sheet on "Surprise me", and a room that keeps playing sees all four before one repeats. The doll's house drawn from the side is deleted.
Why: Karim played the first build and said the room "is looking very childish and not looking good", and asked for "different locations, so that users can choose a different location each time they play". A view from above is how the video the game came from shows it. It also lets the seeker be seen walking to a spot, which a row of doors cannot. A new place each game gives a returning group something new to look at and new spots to argue over, at no cost to the rules: in the rules a place is only a list of nine spot names, and everything drawn lives beside the drawing.
Rejected: the side view, redrawn to look less like a toy (he was shown it beside the view from above and picked the other, and a side view has no room to walk in). A single house, better drawn (the same nine spots every game is exactly what he asked to get away from). A different place every round (the table has to learn nine new spots six times a game, and the seeker's first look becomes a guess at the drawing instead of at the friends).
What would reverse it: a table that finds a floor plan hard to read on a small phone, or spots people cannot tell apart from above. Then a place gets fewer, bigger spots before the view changes. More places are added by drawing one and listing its nine spots.
Decision (made overnight while fixing a fault, Karim to confirm): a phone that is in the room but not in the match reads "Watching", with the live dot, on the quiet line under the game's name in the play header. The card pill the play shell drew over the board is deleted. The hotspot guest's own slim header says it beside the name.
Why: the pill stood in the board's column and pushed every board that fills the phone's height 57 points down, so a watcher had to scroll to see the clock (learnings, same date). The header is 44 points on every screen and every such board already subtracts it, so a line inside it costs the board nothing. The name steps down a size to make room, as it already does for the hotspot host's "On your hotspot".
Rejected: telling each board how much the shell put above it (every such board to change, and a watcher gets a smaller board than a player for the sake of a label); floating the pill over the board (it lands on the mascots and their speech bubbles); moving it under the board (off the screen on exactly these boards); dropping it (the turn card says "watching" only in the games that have a turn card).
What would reverse it: Karim wanting it back as a pill of its own. Then it needs a place with a fixed height that every board subtracts, which today is only the header.
Decision (Karim): "I went to Instagram and found some viral videos in which they are playing some kind of games which can be converted to the games in our app." He sent seven videos and left the laptop to build overnight: "If all seven are possible, build it, or leave the war game. Build the first six games and test it on the web only." Six were built: Colour Clue, Hide and Seek, Doodle Twist, Gibberish, Next Letter and Story Builder. Each plays on one phone and in an online room. The names were picked for him and he may change any. Turning a video into a game means one thing each time: the app is the referee. It keeps the secret, the clock and the score, and judges the answer, so the people only talk, shout, draw and laugh. Story Builder takes the place of the line-up's old "coming soon" entry of the same name, which said one sentence at a time. The video is one word at a time, and so is the game. Why: a video that spread is proof that real groups enjoy that game, and it shows the whole game being played. Nobody has to guess whether it is fun.
Rejected: the seventh video, the card game War: two players, and nobody ever makes a choice. And, by Karim earlier the same day, a competitor's shelf of prompt games (truth or dare, most likely to): "Ignore those games. I have a better idea."
Calls made that night without him. Each is his to overturn. The full list for each game is on its card in karims-taste.html. These five are the ones somebody will ask about:
| Call | Why | Rejected |
|---|---|---|
| Next Letter: the host picks a length (Short, Normal, Long). The lives each player gets are worked out from the size of the table. | Karim's rule from 2026-08-17: how long a game lasts is never set by how many sit down. A game ends when everyone but the winner is out of lives, so the app fixes how many lives the whole table loses and shares them out. A big table gets few each, two players get many. The table loses 7 lives on Short, 13 on Normal and 20 on Long. They are shared between everyone but the winner and rounded up. Nobody starts with fewer than one, and there is no most: two players get 7, 13 or 20 each, eight get 1, 2 or 3. | The same lives for everyone at any table, which was the first draft: a two-player game ended in three minutes (learnings, 2026-10-06). And a least and a most on each player's lives (2 and 9), which the first fix had: two players on Long got 9 where the length asked for 20, and eight on Short got 2 where it asked for 1, so the size of the table was setting the length again. |
| Colour Clue: the clue giver scores by the share of the room that lands close: 3 for half, 2 for a quarter, 1 for anybody. | The game seats 3 to 30. A guesser can score 3 at the most in a round, so the giver must be on the same scale at any size. | A point to the giver for each close guess: thirty guessers would hand the giver thirty points, and the game would be won on the rounds you give. |
| Doodle Twist: a drawing is sent once, whole, when it is done. | Up to sixteen people draw at the same moment. If every line were saved into the one shared game as it was drawn, their saves would knock each other back and reach players as failed moves, worst in the biggest rooms. Nobody is meant to see a drawing before its page turns, so nothing is lost. | Saving each line as it is drawn. |
| Gibberish: a phone is sent a card's nonsense words and never the card's id. | The cards ship inside the app. Whoever holds an id can look the answer up there, so the id is the answer. | Sending the id and letting each phone look the words up, which is less to send. |
| Hide and Seek: the seeker is one of the players, as their own mascot, and the job goes round the table. | The video has a masked figure hunting the hiders. The app is a family table of friends, and its rule is that a player with a role is shown as that player's own mascot. It also gives every player a turn at both sides. | A monster the app plays, with everyone hiding from it. |
What would reverse it: Karim's review of any of the six: none of this is his taste yet. They were tried on the web only, never on a phone, so a real evening may overturn a clock, a score or a whole game. For the source itself: if games from videos turn out to be fun to watch and thin to play, the next ones come from somewhere else.
Decision (Karim): "There are too many unit tests we are writing, and they are piling
up." The tests of screens went in one pass: components, hooks, layout numbers, drawing models, words,
sound levels, pinned copies. What stays is each game's rules.test.ts, the games-wide rule
checks, the engine, the server, the one-phone save and resume, and the few checks on what ships that the
project card names. The app's suite went from about 6,700 tests to about 3,400. The agents' rule cards
(AGENTS.md and CLAUDE.md, "Tests: few") now say: never write a unit test after
the code, no tests of screens, write down how a thing could fail before testing it in isolation, and
temporary things get no tests. Why: builders wrote tests after the code to prove their
own work, so the tests repeated the code and caught little. Every real fault of the tournament week was in
a rule or on the server, which rule tests catch. And the fault Karim found on his phone that day, Sea
Battle's ships drawn cut in half after a shuffle, passed every test there was: "This needs to be visually
seen and caught." He and his friends play the app and report what they see. Rejected:
building played end-to-end flows first (dear to build and to keep while screens change weekly, and nobody
outside the project plays yet), and an agentic end-to-end tool he found (it cannot drive a host and a
guest in one test). Fallow's "no test" score (complexity times missing coverage) is switched off in
.fallowrc.jsonc, since it would flag exactly the code we chose not to test; plain complexity
still gates. What would reverse it: real players. Just before public launch, one played
flow goes in: a host and a guest play one game to the end, leaving a recording.
Decision (Karim): "Happy: Mm-hmm. Hello: Yoo-hoo." The mascot keeps its little voice,
and its cheer, surprise and thinking are untouched. The other two are new recordings under the same file
names (assets/sounds/voice/happy.wav, hello.wav), at the same level, so nothing in
the app changed but the two files. Why: tapping mascots on the home page he found the voice
"a little odd". He was given five whole styles to switch between there, and answered about single sounds:
the style was right, two of its five sounds were not. He was then given five takes of each of those two in
the same place, and picked. Happy matters most of the five: it is also what a tapped mascot plays for a
small move. Rejected: the four other styles (chirps made by code, and three recorded ones)
and the six other takes, all deleted with the panel that switched between them; none of it was ever in a
build a player has. What would reverse it: a sound wearing thin after real evenings of
play: that one sound gets new takes, tried the same way.
Decision (Karim): "Big Band is good." He stepped through the awards show on the development build's preview with three tunes on a switch (the entry below, point 6) and chose the first. It is now what the awards play, for players, in every build, whenever the app's sound is on. What the app does is on the frontend page; this entry is the calls.
1. One tune, one kind of place. The tune plays at the awards: a party's awards show from the curtain to the group photo, and a tournament's podium. On a room's phones the show hands over to a card for that phone's own award and then to the photo; that card is the awards too, so the tune plays through it rather than fading out for one screen and back in. It starts as an awards screen comes in front, stops as it leaves, and the one sound switch silences it. Every show starts it from the top, drum roll first. The deck used to pause a tune where it stopped and pick it up later, which was right for a lobby tune and wrong here: a review played two parties back to back, and the second one's awards opened 89 seconds in. Now a tune whose place has gone is let go once it has faded out, and only the sound going off and on while the same show is on screen carries on from where it paused. Rejected: a music switch of its own (Karim's rule is one switch, and a second one is a second thing a host has to find mid-party).
2. The countdown tune and the countdown place are deleted, not kept for a development build. A second tune played at "next up" and at a tournament's draw whenever music was on. Karim has not judged it, so it could not reach players, and with the switch gone the only way to keep it was a gate that made it play in development builds only: a tune and a place kept for later, with nobody set to hear them. Its file, the place, and the two screens that named the place are gone. What would reverse it: Karim wanting a sound under a countdown. That is then chosen the way this one was, on the screen it belongs to.
3. The file ships, and is fetched only when the awards first open. A phone's build has
it inside the app (a megabyte and a half; the audio a phone build carries goes from half a megabyte to
about two). On the web it is a file of its own that the browser fetches the first time an awards screen
comes in front with sound on, never with the first page, so a visitor who plays one game and leaves never
downloads it. The site's build takes the list of tunes from the app's own (TUNE_IDS), and
refuses to finish with any other music file in it, or with a named one missing: the check that used to say
"no music at all" now says "this, once, and nothing else". The hotspot guest page, which is a whole web
build copied into every phone app, is built without the tune: no screen it can reach is the awards (a test
holds that the hotspot's screens name none of the awards screens), and the file would be in the app twice.
Its build deletes the music from the finished export (apps/app/scripts/trim-guest-music.mjs,
the last step of export:lan-guest), and the test that already looks at the guest bundle that
ships now fails if music is in it. Rejected, after trying it: leaving the file out in the
code, for a build with the guest flag. Built that way straight after an ordinary build, the guest export
still carried the tune (learnings, 2026-10-05).
4. It sits under everything, with room. The level is in the file (its loudest moment is 0.11 of full scale). An award at its loudest is the win fanfare, a cheer still fading as the winner's line starts, and the tune: 0.56 together, and 0.61 if the tune were as loud as the ceiling the test allows a tune. The limit the level test holds is 0.9. Nothing is turned down for the tune, and nothing ducks.
5. The loop is left as it is. The tune is two minutes long, ends on its own ending, is quiet for about two and a half seconds and starts again from its drum roll. A show that runs four minutes hears it twice and its opening a third time. And each phone plays its own copy, started when its own awards screen opened: in a room where several phones have sound on they are close together and not in step. Karim heard it on one phone, on the preview. Whether the rest is too long, and whether several phones at once are a problem, are for his ear at a real party. Would reverse it: the join or the several phones bothering him (the fix for the first is the file; for the second, the tune playing on one phone, the host's).
6. The terms are not settled, and the tune now reaches players. It was generated with
Google's Lyria, whose terms do not forbid commercial use and do not grant it in writing
(assets/music/CREDITS.md). Nobody outside the project plays yet. It is to be read again, and
settled, before the app is public or sold.
Deleted with the pick: the two tunes that lost, the switch on the preview and the one key that turned sound on there (the preview plays what a player would hear, and has the same sound switch as the show), and the store that held which tune was on.
Decision (Karim): he played with the Sound lab (the two entries of 2026-10-04 and 2026-10-05 below) and ruled on every part of it. What the app does now is on the frontend page; his words are in the taste doc; this entry is what was kept, what was removed, and why.
1. What was removed. Everything that existed only to choose between options, and every option that lost, in the same change, with its tests and its words:
ui/audio/tones.ts) the engine plays straight to the speakers,
as it did before the trial. The three honest names the trial added (place, take,
burst) stay: they cost nothing, and each plays the notes its moment always played.lineOf), the mark on waiting lines, a bubble's
sayKey and speaker, the Chess stage's hush while a piece moves, and the quiz
question that was said but not typed. The entry "Which line a text is" below is history: there is no
spoken countdown left for it to govern.lab.json), and the
script that cut the trial's audio out of the hotspot guest page.2. What was kept, because a pick needs it. One mouth (one voice sound at a time, words win, a feeling never queues). Every secrecy rule exactly as it was: a board that hides something is silent except in the moments it marks as shared, and a sheet is as silent as the screen that opened it. The music director and the record deck, with two places left (a countdown, the awards). The test that adds up the loudest things that can sound together, and the test that holds every game sound note for note to what the app played before the trial: a shipped build's game sounds are, sample for sample, what they were.
3. A tap is the person asking, so it is heard where a mascot is otherwise hushed. The
home page is hushed on purpose: its mascots act by themselves all day, and a cheer every few seconds from a
page nobody is playing on is a nuisance. That hush also swallowed the one reaction somebody asked for. So a
mascot has two doors (ui/mascot/useMascotLife.ts): what it does by itself obeys the hush, and
what it does because it was tapped does not (useTapVoice). A tapped mascot is always heard: its
own feeling when it answers with a big move, a giggle when it answers with a small one, because a tap that
is sometimes silent reads as broken. It still obeys a board that keeps secrets: a sound reaches the whole
room. The five recordings are fetched as soon as sound is on and the page has had its first tap anywhere
(a browser allows nothing before one), so a mascot tapped later finds them ready. Only when that first tap
is itself on a mascot does the voice wait for its file: it is voiced as the file lands if that is within
four tenths of a second, and that one tap is silent otherwise (on a slow phone network it usually will be;
the next tap is heard). A recording that would not read is asked for again the next time its feeling is
wanted, so one bad moment on the network does not leave the mascots mute. Rejected: taking the hush off the home page (the loop would be heard); a separate
"tap" noise (he asked for the mascot's sound, not a click).
4. Only the big feelings, and a wave is not one of them. A mascot waves for many
reasons: as it comes up for its turn (on the rim eight games share, where four of them have it wave, on the
screen the phone is passed on, in Quick Draw and in Charades), as Trivia's quizmaster cueing each question when a friend hosts, as a goodbye when
a room closes. With the hello's sound attached to the wave, each of those said "hi": twelve in one game of
Chain Reaction, ten in one of Trivia, and a cheerful one as a room closed. The first fix silenced the turn
wave where it was asked for, and a review then found the other two, because the sound was still something a
wave had and every caller had to remember to take away. So a wave makes no sound, and a hello is its
own move (greet in ui/mascot, the same wave by another name), asked for by
that name in three places: at a room's door as you reach it, by somebody already in the room as a friend
sits down, and by your own mascot when you tap it in the room. A test reads every source file of the app
(the mascot's own folder and the dev-only gallery left out) for that name written in quotes, and fails when
a fourth place has it. It finds the word wherever it is written; it cannot find a name put together in
code. Rejected: a flag on each wave that should be quiet (the next wave forgets it); a
list of the games that wave. The presenter beside "What are we playing?" is the same lesson in another
place: it cheers each card that is opened, which was silent on the hushed home page and heard on the Games
tab, so the heading now hushes its own presenter wherever it is drawn.
5. What a player hears that they did not before: the little voice when a mascot feels
something big or is tapped, and the mascots saying their lines in a party's awards show. Nothing else.
Music stays off for every player until an awards tune is chosen and the lobby design is
settled, and a build that ships does not carry a tune at all: the music files are read only in a
development build (ui/audio/music/tunes.ts). For the website that is held by its build, which
refuses to finish with a music file in it (scripts/build-site.mjs); for the phone apps nothing
automated checks it, and it rests on the same gate, read by hand in a web build. The audio a shipped build
carries falls from about 15 MB to half a megabyte (the five voice recordings and the dice).
6. One temporary thing: the awards tune is chosen on the awards preview. The
development build's awards preview (/party-preview) has a small switch over the show: no music,
or one of three tunes ("Big band", "Game show", "Parade"), and while sound is off one key that turns it on.
It is the only thing that turns music on (ui/audio/music/picked.ts), it is not remembered, and
it turns music off as the page is left. After the pick: the chosen tune becomes what the
awards play, the other two files and the switch are deleted, and whether music is on becomes the app's own
setting. Before a tune ships, the terms it was generated under are read again
(assets/music/CREDITS.md).
The lesson: he judges a sound inside a played scene, not from a panel of options. The lab was the wrong tool. It was a sheet of choices (five sets, three voices, five tunes) opened beside the game, and he said so: "this is not the right way to analyze the sound effects because I need to literally play the game to see the sound. I can't directly judge it. Maybe I should have asked for you to create a setup where I can play the scenarios directly, like a player move, a winning screen, or the award show." What decided everything was playing real scenes: a game told him the talking was annoying, the awards show told him where talking belongs, the home page told him a tap must answer. So a choice of taste is put in the scene it belongs to (the tune switch sits on the awards show itself), with few options, and the scene is what gets built first. Two more things the week taught: most of what was offered lost to what was already there, so "keep today's" is a likely answer and options should be cheap to delete; and two days went on making five-way machinery correct (levels, timing, loading, two voices at once) for choices he settled in a sentence each once he could play.
Rejected: keeping the lab for the next choice (the next choice is made in its scene); keeping the sets that lost "for later" (dead weight nobody will hear); a setting that lets a player turn the talking back on in games (he found it annoying, and a setting is a second product to keep right); shipping music with the first awards tune (not chosen, and its terms are not read). The work on a feeling and a line sounding together (a closed pull request) is dropped: a line is now said only in the awards show, where words winning is what he heard and liked. What would reverse it: the little voice wearing thin after a few evenings (it becomes rarer, or gets a switch of its own); a game where a spoken line is the point (that one moment says its line, as the awards do); the lobby's record player being designed (music gets its places back, and its own setting).
Decision (Karim): "Best of three in tournaments: remove." A tournament match is always
one game. One result, then the toss if it is a draw. His reason: "at any point in time, only one game will
be played at any stage of the tournament", so the room never waits on extra games. The start sheet no
longer asks how long a match is, and the server no longer takes the answer
(programmes.startTournament has no such argument, and matchSettings in
packages/engine/src/programme/match.ts starts every match with rounds: 1).
Outside a tournament nothing changes: an ordinary room and one phone keep their series, "Play the rest"
and the sheet between rounds.
This dissolves the rules of this week, it does not guard against them. Five things were built so that a longer match could not hurt a waiting room. With one game a match none of them has a moment to happen in, so each was deleted with its tests and its words, never left behind a switch:
CLOCK_WAIT_MS, clockWaitEndsAt) and the
foot of the sheet that drew it (ui/roundFoot.ts).ENDS_DECIDED_SETTING, Series.endsDecided and the series calling itself. One
game is decided by ending.calledOnScore,
matchIsOver, the wait handed through ui/MatchFrame). The sheet never opens on a
tournament board.score.ts), the watchers' "That round's mine!", and the table kind "the two players
start their own rounds" with its refusal "Only the two players can do that."A tournament match sits on a table nobody runs. There is no next round to start, no
series to finish and no mode to switch, so the server starts every match with a key of its own
(FIXED_TABLE_SETTING, laid on by matchSettings beside the opener) and the
table's controller is FIXED_TABLE: every table
action is refused with the one line a fixed mode already had. The old kind let the two players run a
table that had nothing left to run, which is a control that can only refuse.
That key is the server's alone, and so is every other reserved key, by construction.
"Nobody runs the table" is not one more answer to "who may switch the mode", because that question is the
host's phone's to answer and this one never is: sent to an ordinary room it would leave the table with
nobody to start round two. So it is a reserved key, and the reserved keys are one list beside where they
are defined (SERVER_ONLY_SETTINGS in packages/engine/src/types.ts).
stripClientReservedSettings deletes exactly that list at every door that takes settings: the
lobby's mirror, the start, play again, a party's games and a tournament. A test fails for any settings key
the engine exports that is neither on the list nor named as the phone's own, and sends every key on the
list to every door (convex/engine.settings.test.ts). Who the host is cannot be deleted,
because games read it, so it is written over with the room's own host instead, now by the start too (play
again, a party and a tournament already did): a host id a phone made up would stall a table the same way.
__endsDecided is not a reserved key any more, because it means nothing: a phone that sends it
sends an answer no game reads, like any other unknown key.
Kept, each with the caller that still needs it:
betweenRounds,
seatsGone in the table kit): an ordinary room's guest can leave while the table waits for
its next round.ui/knownNames.ts): the
ordinary room's round sheet.boardRoster): somebody walking out in the middle of a tournament game.ui/clock/readout.ts): the first-move
line, for the players and for the people watching.MatchFrame's called): a clock running out in the middle of a turn.callOnClock, the result's 'score'):
the backstop of a stretch the game times itself, and the stored match below.A tournament stored while a match could be best of three is not migrated and does not
crash. Every match still to start is one game. A longer match that is being played is finished by the
game it is in: when that game ends nobody is on turn and nothing is timed, so its clock has already run
out (clockRunsOutAt) and the job that calls clocks calls it at once, on its score in games as it
stands: whoever is ahead goes through, and a level score goes to the coin. So a match in its first game goes
to whoever takes that game, or to the coin if it is drawn. A match in its second game goes to the leader if
the leader takes it or it is drawn, and to the coin if the other player takes it (one game each). "Play
again" on such a tournament starts one-game matches. All of it is in
convex/programmes.oneGame.test.ts. The cost is one line in
the platform's clock that only such a match reaches (clockHolderOf still asks whether a table
is between rounds); it can go once no such tournament is stored.
Proof that an ordinary room is untouched: every game with a series (nine of them) was played by the same seeded random play on the code before and after, 198 runs and 1,188,000 steps, comparing every state, refusal, result, between-rounds answer, live score and carry-over. No difference. The engine's and the server's tests play one game a match in all three formats, in the final and in the match for third place, to a win and to a draw.
The objection that stays open, on purpose: Tic-Tac-Toe between careful players is nearly always a draw, so a Tic-Tac-Toe knockout is mostly coin tosses. Karim knows and accepts it, and nothing is built around it. What matters is that the toss reads as a moment and never as a fault: the coin stands where the board stood, each face is a player, the host says "A draw! Coin time." and the winner says "The coin picked me!". That screen is unchanged here.
Rejected: hiding the question and keeping the longer match in the code (nothing could reach it, and Karim does not keep that). Playing a stored longer match out as it was started (that keeps the wait, its lines, the decided rule and the leaver between games alive for data that lives as long as one room). Ending a stored longer match as void (it throws away a game two people played). Keeping "the two players run their table" with one round (a table whose every action refuses).
What would reverse it: Karim asking for longer matches again, for example a final that is best of three. It would come back as the tournament's own rule for one kind of match, with the wait between games designed first, because that wait is what the room pays.
Decision 1: a mascot says a line once, when it is shown anew, and whether a text is a new
line is decided by its words with the numbers taken out (lineOf in
ui/audio/voice/line.ts). "First move in 0:58" and "First move in 0:57" are one line, so a
countdown is said when it appears and is only read after that. Why: a bubble was said
whenever its words changed, and a count is words that change every second: in real rooms a first move's
minute was spoken sixty times, on every phone. The rule sits where a line gets its identity, so a counting
line written tomorrow is covered the day it is written, and a test tries it on every line in the product.
Rejected: a list of the lines that count, or a marker on each counting bubble (the next one
is the one that gets forgotten); saying the count at each ten, or the last three (a voice that counts down
is a feature nobody asked for). What it gives up: a line whose only change is a number that
matters ("3 left!" turning into "2 left!") is said once. A bubble that wants its words said again says so
itself (sayKey), which is what a kept turn already does. What would reverse it:
a game where hearing each number is the point: that bubble passes a key per number, and nothing here
changes.
Decision 2: the mascot's waiting and loading lines are read, never said. A moment marks
itself (waiting on MascotMoment); the loading mascot (MascotWait) and the
pack sheet's "Writing your questions…" do. Why: a wait is over when it is over. The loading
mascot shows after 0.3 s and the board was usually there a few hundredths of a second later, so its line was
a blip of two or three letters, and because a new line takes the mouth, that blip cut off the real line being
said ("Ana's turn"). It is also simply not speech: nobody announces to a room that a board is loading.
Rejected: saying any line only once it has been on screen for a while (every real line would
start late to protect against one kind that should not be said at all); letting the turn line finish and the
loading line queue behind it (it would then speak after the board had arrived).
What would reverse it: Karim wanting a long wait voiced (the pack's takes two seconds or
more): that one moment drops the mark.
Decision: the night before Karim listens, a second pair of eyes went through the trial (the entry of 2026-10-04 below) and found ten faults our own checks had passed. Each is fixed at its cause, and each fix has something that fails if it comes back. What the faults were is in learnings, 2026-10-05; this entry is the calls.
1. Only the screen in front is a place. A screen used to count as "the lobby" for as long
as it was alive, and the app keeps a screen alive under the one opened over it: Back out of a room opened
from a link put home on top, and the lobby tune played on over home and every screen after. Now a screen
counts only while the navigator says it is in front (placeInFront in
ui/audio/music/director.ts, used by useMusicPlace). Rejected:
stopping the tune at each way out (there are four of that Back, and Profile opens over a live room too:
the next one would be missed the same way).
2. A lab pick is heard when its files have landed; a failure is said, and tried again. The sample used to play 60 ms after the pick whatever had arrived, so a first tap on a recorded set or the little voice could make no sound. The sheet now waits for that row's own recordings, says "That one didn't load. Tap it again." under the row when they cannot be read, and the next pick reads them again (the bank forgot nothing before: a file that failed once stayed silent until the page was loaded again). The same for a tune: one that will not play is let go, and a fresh player is made the next time it is wanted. In a browser the deck now holds the page's own audio element rather than the audio library's wrapper, because that is the only way to hear a tune fail; through the wrapper the failure was an error nobody caught. Rejected: a time limit on the wait (a pick that says nothing is the fault being fixed); reading a failed file again on every tap (a tap storm would be a storm of fetches).
3. One level means one peak, set last, and held from both sides. Every recorded effect
and voice file now has its loudest moment 3 dB under full scale, set on the finished one-channel file.
Ten were off (seven 1 to 5 dB short, three half a decibel over) because the level had been set before the
two channels were folded into one, and the test held a ceiling only, so a quiet file passed. The test now
holds each file within a twentieth of a decibel. Loudness is measured and reported, not
evened out: a bell and a knock that peak alike do not sound alike, and the numbers are beside the
files (assets/sounds/CREDITS.md). Rejected: squashing each file to one
loudness (there is no compressor in the chain by design, and it would change what each take is).
What would reverse it: Karim hearing one sound as too quiet or too loud in the set he
picks: that file gets a new take or a gain of its own, written beside it.
4. "Nothing changes until Karim picks" is held by a test. The default set's notes were
moved, not rewritten, but only one of them was checked: a reviewer changed another four ways and
everything passed. sets/today.main.json is the app's sound as it was before the trial, written
down by running that version's own engine file on a context that records what it is asked: every name's
notes, and what playing them schedules. today.pin.test.ts plays today's set the same way and
compares. Rejected: restating the envelope's arithmetic in the test (a second copy that
agrees with the first by being typed by the same hand).
5. Fewer voices at once. A whole chess game was played with each set while everything the engine was asked for was written down. One sound per move, every time, 12 to 21 ms after the tap. But the stage said two lines nobody needed: the mover's own "Ben's turn" as Ben's piece set off (a quarter second of it, on top of the move's sound, cut as the piece landed), and the next player's turn line for one frame before the mover's "Check!". So the Chess stage is hushed while a piece is on its way, and a line that is replaced before its first letter makes no sound. A line is also written half a second ahead of the clock rather than all at once (a long one was sixty oscillators in one go, paid for even when it was cut after two letters), and the marimba loop a second and a quarter ahead, so a screen that freezes for a second costs it nothing.
6. A sheet is heard as the screen that opened it. A phone's sheet is drawn at the app's
root, outside the silences round the screen that opened it. Nothing leaked, but the next sheet opened by a
board with secrets would have been voiced. Every sheet now carries in what may be heard where it was
opened (ui/SoundZone.tsx).
7. The hotspot guest page is built without the trial's audio. That page is a whole web
export copied into every phone build, so it carried the 15 MB a second time, for a sheet nobody on it can
open. scripts/trim-guest-audio.mjs deletes the trial's recordings from it after the export (the
dice throw stays). A test fails the day a default plays from one of those folders, which is the day the
script must change.
8. Names: what was changed and what was left. Chess and Checkers now ask for a piece put
down and a piece taken. Left, on purpose: Ludo plays slide as a captured token
rides home, and that is what the name means, a token sliding a long way back for about a second, in every
set. Pairs, Reversi and Dots and Boxes use daub for their small move, a soft stamp: giving each
its own name would mean three names that are the same sound everywhere. A clock running out plays
wrong, the same "no" as a refused tap. Still wrong in one set: Sea Battle's
shot plays swoosh, which is also the line through a winning row; in Real toys that recording is
a pencil stroke. Only a new recording fixes it, and only if that set is picked.
Decision: a tournament match is over the moment it is decided. At two games to none of a
best of three there is no third game, and nothing offers one. The tournament says so in the settings it
starts every match with (matchSettings in packages/engine/src/programme/match.ts
lays the reserved key ENDS_DECIDED_SETTING beside the opener), the table hands it to its series
(Series.endsDecided), and a series told so calls itself as the deciding round is
recorded (recordRound in packages/engine/src/series.ts): the rounds become
the rounds played, which is exactly what a player's "Finish now" does. The match then ends on the path every
match ends on, in the move that decided it. Outside a tournament nothing changes: a decided series is still
its players' call, "Finish now" or "Play the rest" (the entry of 2026-09-11 below stands there).
Why: a room is waiting on the result, and a won match must stay won. The sheet used to offer "Play the rest" at two games to none, and a dead game is still a game on a clock: the leader who sat it out lost the MATCH to the player they had already beaten (a turn clock in Tic-Tac-Toe, the first-move minute in Chess, and a leader walking out of it lost it too). Each rule was right alone. Together they could take back a match the room had watched somebody win.
Why it lives where it does. The tournament is the one who knows a room is waiting, so it
is the one who says it, in the one place it says what a match is started with: every format and every kind
of match (a round's, the final, the one for third place) starts from there. The series is the one place
every game records a round, whatever settled it: a line, a flag, a resignation that never passes through the
table's own reducer. So no game is named, no game was changed, and a new two-player game is held to it by
handing its settings to its table, which tournamentReady.test.ts checks. The key is the
server's alone: it is taken out of anything a phone sends (stripClientReservedSettings), like
the opener.
Is there any other way a decided match can change hands? Asked, and answered with tests
(convex/programmes.decided.test.ts, the engine's table.test.ts): no. The move that
decides a match ends it on its own row and stops both clocks. A clock job, the wait between two games and
the game's own timer all find a match that is over and do nothing. Anything sent late is refused. A result
told twice, the other way round or as a draw, changes nothing. A player who leaves afterwards keeps what
was played, even when the room had not yet read the result: played results are read before leavers.
Rejected: reading "the two players run their own table" as "this is a tournament" (no new key, but it ties two things together that only happen to coincide, and a phone can send that setting). The platform calling the match when it sees a decided score (it cannot see inside a game, so every game would need a new hook, and the dead game would exist for a moment). Hiding the button and keeping the rule (a third game could still be started by anything that sends the action). A second check beside "is this the last round" in the table (two ways to be over: the 2026-09-11 entry rejected that for the same reason).
What would reverse it: a tournament that counts games, not matches (a league on game difference), where the third game is worth playing.
Decision: while a sheet is open on the web, everything laid down before it is
inert (the screen behind, and a sheet under a sheet), focus moves into the sheet and back to
whatever opened it, Tab goes round inside it, and Escape closes it unless it is locked
(apps/app/src/ui/sheetFocus.ts, used by the shared Modal). The sheet is one
dialog named by its title; its backdrop is a Close button for a screen reader and is not a Tab stop. The
sheet library's own "slider" roles are switched off on the web, where a slider's contents are not read out.
Why: a big screen's dialog is the browser build's own modal and already did all of this. A phone's sheet is drawn into the same page as the screen under it, so Tab walked into the lobby behind, Escape did nothing, and a screen reader read the lobby first.
Rejected: aria-modal on the sheet (the backdrop sits outside it, so a
screen reader on a phone would have had no way to close a sheet that has no button of its own). A keyboard
trap alone (it keeps Tab in and leaves a screen reader free to wander behind). Drawing the phone sheet
through the browser's modal on the web (a second sheet to keep in step with the native one). A wrapper round
the screens in the root layout (it cannot make a lower sheet inert, and the sheet already knows where it
was laid).
What would reverse it: the sheet library drawing its sheets somewhere other than beside the screen. The rule "what was laid before the backdrop is behind it" would then find nothing, which fails safe: Tab still stays inside, and the screen behind is as reachable as it was.
Decision: the app can now make its effects five ways, give the mascot a voice, and play music, and none of it is switched on for players. A development build has a sheet, the Sound lab, where Karim switches between the options while he plays; a build that ships plays exactly what it did before. Why: Karim, 2026-10-04: "One major issue I felt is sound effects and the music. Our game is lacking those", and then, asked to choose from a listening page: "Can you add this to the app and give me an option to switch between the music and the sound effect so that I can actually see and feel it visually and also make a better judgment?" A sound cannot be judged away from the screen it belongs to, and nobody who built this can hear. What the app does today is on the frontend page; the reasons are here.
1. A sound set is a list, not code. A screen asks for a sound by what happened ("a piece
was put down"), and a set says how each of those sounds. There are five: Today, Toy box, Bubbles, Real toys,
Cartoon. Today is the default and is note for note what the app has always played: its notes
were moved, not rewritten, a test pins them, and it does not pass through any of the new machinery. Three
names are new: a piece put down (place), a piece taken (take) and a chain set
off (burst). In Today each sounds like the name boards borrowed for that moment before
(slide for a piece put down, swoosh for the other two), so a board that switches
to the honest name changes nothing anyone hears. Chess and Checkers (a move is place, a
capture take, and a check or a crown stays claim), Sea Battle's ship set down
(place) and Chain Reaction's chain going off (burst) have all switched, each with
a test that holds Today to the notes it played before. Chain Reaction makes its one noise only when a placement sets a chain off (an orb that
bursts nothing is silent), and the name it borrowed, swoosh, is the line drawn through a
winning row: in Real toys that is a pencil stroke. The two made-by-code sets have a burst of their own: four quick knocks (Toy box) or pops
(Bubbles) running up, over in about a third of a second. Nobody has recorded one, so the two recorded sets
play their take, a knock and something leaving, until one of them is chosen and gets its own.
The thrown dice stays the one recording Karim picked, in every set.
Why a list: switching the whole app's sound is then one choice, and dropping the sets that
lose is deleting their files; no screen changes either time. Rejected: a switch inside the
screens that play sounds (every board would know about the trial); a second sound engine beside the old one
(two things to keep in step); sending Today through the new volume controls for tidiness (it would no longer
be exactly today's sound, and "what we have now" has to be one of the things he compares).
2. How loud things are is set by measuring, and nothing squashes the sound afterwards. Each kind of sound has one fixed volume. A test plays every sound of every set into a recorder and checks three things: each is loud enough to hear, none is louder than a fanfare should be, and the loudest things that can sound together (two effects, a voice and a tune) stay under nine tenths of what a speaker can take. The worst case measured is 0.87 of full. The recorder the test uses was checked against a real browser and agrees with it. Why no squashing step (a compressor): the audio library on the phone has none, so it would be web only and the two would sound different; and it would be hiding a volume mistake rather than fixing it. What that leaves: three effects and a voice and a tune landing on the same instant could still pass full; two is the most a move makes.
3. A recording starts as promptly as a tone. A set's recordings are read into memory when
the set is chosen, and play from there. Measured on the web with the same button: 63 ms from tap to sound for
Today's tones, 61 to 63 ms for a recording, 67 ms for Toy box, of which 60 ms is the lab's own pause before
its sample. A recording still on its way plays when it lands if that is within a fifth of a second (89 ms the
first time over a local network) and is skipped otherwise. Rejected: playing them the way
the dice is played, through the media player (it starts later and cannot vary the pitch of a sound heard
often); loading every set at launch (megabytes nobody asked for). The files are .wav because the
phone's decoder reads that and not .m4a.
4. The mascot has one mouth. A line in a speech bubble is babbled once as it appears, at the speaker's own pitch, with the letters arriving in step. Five feelings have a sound (cheering, laughing, surprised, thinking, waving hello), wired in at the one place every mascot plays a reaction. Words win, and feelings only fill silence: a line cuts off a feeling that is sounding, and a feeling that arrives while anything is being said or felt is dropped, not queued. Why: a room of mascots all reacting at once is noise, and a queued giggle arrives after its moment. A line is said when it is shown anew (the bubble appears, the words change, or the same words are said again for a new turn), never because the screen redrew. A long line is hurried to finish in about two seconds. Rejected: a voice per mascot (they stack); real speech (it would need every line recorded in every language).
5. Where a voice could tell a secret, or would never stop, there is none; a game says which of its moments everyone shares. The board of any game that hides something from some players is silent, decided from the game's own rules (it has a filter for what each player may see), so a new such game is silent without anyone remembering to list it. The home page and a winner's dance are silent too, because their mascots react in a loop, and nothing can switch those back on. Why so blunt: a giggle on the phone of the one player who can see the answer is a leak, and on one shared phone every sound is heard by the whole table.
A game then marks, moment by moment, what every screen is shown at the same instant, and those
are heard (PublicMoment). Never the whole game at once: each mark is one judgement a
later reader can see, and each is held by a test that plays real matches and puts every seat's own view (and
a watcher's) through the very decisions the screen is drawn from
(games/<id>/voiced.test.ts). Three games are marked for the trial, so the voice can be
judged in more than the open board games:
The question is said, not typed. Everywhere else a babbled line appears letter by letter. A quiz question is read against a clock, and babble is each device's own choice, so typing it would cost only the players who have it on a second or two. It is all on screen at once, and said in at most 2.2 seconds however long it is. The other six games stay silent (Charades, Quick Draw, Spy Grid, Tambola, Undercover, Word Search): each has a moment that is one phone's own. Rejected: a list of games to hush (a second copy that goes stale and fails open); lifting a whole game's silence because "nothing in it leaks" (Sea Battle and Trivia each turned out to have a moment that is not everyone's, and only a per-moment mark could say so).
6. Music is picked in one place, from where the player is. A screen declares a place (lobby, countdown, awards, game) and never starts or stops a tune. One director hears every place on screen and picks: awards over a countdown, over a game, over the lobby. Why: two screens are often up at once (a countdown over the lobby, an awards show over a room), and a screen that owns its tune has to know about every other screen's tune to hand over cleanly. With places there is nothing to hand over: a place goes away and the director picks again. Rejected: each screen playing its own tune and stopping it on the way out (the first overlap plays two, the first crash leaves one on). Music is made quiet in the files themselves (a tune measures 3 to 7 units under a turn chime on the loudness scale broadcasters use), because a browser on an iPhone ignores a page's volume setting; the player's volume only fades.
7. The lab is temporary, behind one flag, and built to be deleted.
ui/audio/soundLab.ts is on in development builds only. Off, the sheet and its ways in are not
drawn and a choice a development build left on the device is never read. After the pick: his
choices go into the defaults (ui/audio/choices.ts), the sets, voices and tunes that lost are
deleted with their files, and the flag, the lab's folder, its words and its ways in go with them; what is
left is one set, at most one voice, and the director. The cost until then: the options add
about 15 MB of sound and music files to every build (11 MB of it music). Rejected: a
settings screen for players (it would ship five half-chosen looks of the product); a separate listening page
(he had one, and asked for the app instead).
8. Where the sounds came from is written beside them. The recorded effects and the little
voice are generated with ElevenLabs sound effects, which Karim's plan allows inside a paid app. The six
recorded tunes are generated with Google's Lyria: its terms do not forbid commercial use and do not grant it
in writing, and each track carries an inaudible mark that it was generated. That is the weaker of the two,
and it is the line to read again before a tune ships. ElevenLabs music was tried and is left out on purpose:
his plan does not allow it inside a paid game. (assets/sounds/CREDITS.md,
assets/music/CREDITS.md.)
Not part of this: how the app's sound behaves with an iPhone's silent switch and with music already playing from another app. It was set aside before the build and nothing here changes it. Not heard by anyone yet: every sound in this entry was placed by measurement. Whether Toy box is charming or grating, whether the babble is cute or tiring after ten turns, and whether any tune suits the lobby are exactly the things the lab is for. Would reverse it: Karim finding, after an evening with it, that a mascot with a voice is too much (the voice goes, the sets and music stay); a recorded set winning and its 20 small files proving slow to load on a real phone (they would move into the app's first load); a tune he wants that needs clearer terms than Lyria's.
Decision: a tournament match that ends level is settled by the coin there and then, in
every format (knockout, everyone plays everyone, set rounds) and every kind of match (a round's, the one for
third place, the final). The toss is the tournament's own, from its seed and the match's place in it
(matchDrawn in packages/engine/src/programme/tournament.ts), and the match
remembers it was chance (how: 'chance'). A best of three that ends level is a draw like any
other. Best of three stays the host's choice.
Why: Karim, 2026-10-04: "For the tournament, when there is a draw, we are going for a retry... I think that will cost other players a wait time. Maybe instead of that, a chance is a better option here. If there is a draw, a toss will happen, and depending on the toss, whoever wins will proceed to the next round. That will remove the wait for other players." A round ends when its last match does, so one replay held everybody for another whole game, and with a clock each that is minutes.
The toss is still a moment. The two players keep their drawn board for a beat, then
watch the coin fly where the board stood. Everyone watching keeps the drawn board with "It's a coin toss!"
over it, then "The coin picked me!". The coin's timetable is counted from the moment a phone hears, the
same in every game: tossed, then down for its two players COIN_LANDS_MS later. A drawn board
gives way to the coin when the toss is due, however long its game holds a finished board
(coinDue), so the coin never lands late. Everyone watching is told a beat after it is
down, never before (COIN_TOLD_MS), and no phone shows the next round's pairs
until that has been read (COIN_READ_MS, coinsReadAt, all in
modes/online/tournament/flow.ts): the pairs would say who it picked. Until its draw is shown,
a watching phone stays on the round that has just ended (roundOnShow). The server's ten-second
countdown to the next round is longer than all of it, so a match never starts under a coin.
What went with the replay: a match has one fixture, so the "which game of the match"
number is gone from the rules, the wire and the screens, with the opener swap for a replay, its lines
("A draw! I go first.") and the "rematch" label on the bar. The fixtures.game field stays in
the schema as an optional field nothing writes, because rows stored before this carry it.
A tournament stored while a drawn match was still being played again plays that game out
and its result stands (a draw in it is the coin's); the screens are shown the game being played, once
(drawnFixtures in convex/programmes.ts). Tests:
tournament.test.ts, fuzz.tournament.test.ts (a result settles its match there and
then), convex/programmes.test.ts.
Rejected: (1) Keeping the replay, even as one quick game: it is the wait Karim asked to remove. (2) Letting a league draw stand as half a win each: the table would need a third result, its tie-breaks (the result between two players level on wins) would have to read draws, and "Won 2" would become a points column to explain. The coin keeps one kind of result in every format. (3) A silent result: a match decided by nothing anyone saw reads as a bug.
What would reverse it: a format where draws are common and decide a title (a chess league played for its own sake): then a draw that stands, with a points table, is the honest rule, as an option of that format. Built 2026-10-04, awaiting Karim's review.
Decision: four calls after the chess clock was played by a room.
1. The first move is free of the clock, everywhere. A game on its own total clock
(Chess) arms no deadline until both players have made their first move of the game
(startedClocks in games/chess/game.ts). Each game of a best of three starts the
same way, so whoever starts the next game costs nobody a second.
2. In a tournament the platform asks "are you there": one minute for a first move. The
rule is one sentence in the engine: in a match on the game's own clock, a turn the game is not timing
is the platform's, for one minute (clockHolderOf, FIRST_MOVE_MS,
matchBudget in packages/engine/src/playClock.ts). The only turns such a game does
not time are first moves. The minute is the platform's ordinary clock each, kept as a clock PER TURN:
whoever is handed a first move takes a whole minute, however their last one ended (their own move, the other
player resigning under it, the round settled, a wait gone by: taken in the same file). So it
runs out the ordinary way: the existing scheduled job (programmes.flag, version-tagged,
re-armed if it wakes early) calls the MATCH for the other player, the same door a leaver's match goes
through. Both players and anyone watching are told who has to move and until when
(firstMoveBy). Outside a tournament (an ordinary room, one phone, a party, the hotspot) there
is no platform clock, so no minute: the first move simply waits.
3. The safety net names a loser. Should the game's own wake-up ever miss, the
platform's backstop tells the game its time is up through the same door the game's own deadline uses, and
the game names who ran out (clockRanOut in convex/engine.ts). A game that gives no
answer is still called on the score. A true draw goes to the toss.
4. A tournament and a party open on three minutes each; an ordinary game on five. A room is waiting on every tournament match. The tournament's answer for the clock is part of the tournament's own pick, so it does not move what the ordinary sheet opens on. A match given no answer plays the default length; only the answer that says "no clock" means none.
Why: with the clock running from the start, an absent phone held a round for a whole clock, and in a best of three the player who did not open the next game could start it and burn the opener's time. Both are one fault: time running on somebody who has not touched the board.
Rejected: (1) A new timer for the first move (its own job, its own
field): the platform's clock each already is a guarded, re-arming timer that gives the match to the other
player. Reusing it added no path on the server. (2) A no-show loses only that game: in a
best of three the room would wait out two more minutes for the same dead phone. (3) The minute in
every room: in an ordinary room nobody else is waiting, and the host can pause. (4) Asking
by game id: the rule reads rules.totalClock and the armed deadline, so the next game
with a clock of its own gets it free, and the other two-player games keep the clock rows they had, byte for
byte (the backstop is its own field, hold, written only for a game on its own clock).
(5) Calling a missed flag on the score: level on score is a draw, and a draw is now a
coin, so a player who ran out could have gone through.
What would reverse it: if a minute proves too short for a room on a bad connection, it is one constant. If people want the minute in ordinary rooms too, the rule moves from "a match on the platform's clock" to "any online match", and needs a clock row there. Built 2026-10-04, awaiting Karim's review.
Decision: all 98 mascot lines that four passes rewrote on 2026-10-04 are back, word for word, in the banks they came from: Checkers 16, Dots and Boxes 17, Sea Battle 9, Chess 18, Reversi 10, Pairs 5, the tournament's banks 13 and the party's awards 10. Sixteen more, changed for the same reason earlier that day, are back as well (below). The rule they were rewritten to fit ("a mascot never pokes at the other player; it brags about itself or says something plainly fun") is withdrawn, and with it the two that grew out of it: "a bank says one idea once" and "no line in two of the tournament's winning banks". In the three entries below this one that reverses point 6 of the follow-up review (no line twice across the tournament's winning banks), the tripwire and the replaced lines in point 5 of the last review, and points 9 and 10 of the independent review (the net of patterns, and "a bank says one idea once"). The pinned list those entries describe stays. The entries themselves stand as written: this log is not rewritten.
Why: Karim read old beside new, all 98, and ruled: "Mascot line changes are all looking bad. The previous version was good. The previous ones sound and feel like the mascot is talking. The ones we currently have feel like some kind of commentary is running, so I prefer the previous version." The mascot talks TO the people at the table, so a line said to the other player ("Thanks for that.", "Bow down.", "Too easy.", "Told you so.") is the mascot talking, and that is the voice. A line that only describes its own move ("A little souvenir.", "Light on my feet.") reads like commentary. Nobody had asked him before the rule was written: it grew out of a review that found "a few lines that poke at the other player" (learnings, the same day).
What the line test does now (apps/app/src/games/mascotLines.test.ts). It
keeps the pinned list (mascotLines.pinned.json), written out again from the restored banks:
every line of every bank is on it, so a new or changed line fails until the list is changed in the same
commit, and then it is in that commit's diff, where it can be shown to him. It keeps two small checks that
are true of the old banks: no line twice inside ONE bank, and a winner's line at a party's awards is never
the award's own name. Gone: the net of patterns that refused a line for speaking to the other player
("you", a thank-you, an order, "too easy"), the list of lines let through by name, the list of banks
allowed to speak to the table, and the made-up lines the net was measured with. Gone too: the same check
in Pairs' own test, and the sentence in Pairs' card that its lines are never about the other player.
Rejected: keeping the rule and putting back only the lines he likes least (he ruled on the set, and the rule was never his). Keeping the net of patterns "as a tripwire" (it holds a rule that is withdrawn, and it caught three of thirty-two lines it was tried on). Dropping the pinned list with it (the list is what makes a changed line visible, which is the one thing that was missing). A check for the same line in two different banks (the old banks say "Too easy." and "Told you so." in several, and he prefers them).
The earlier lines are back too. Two earlier fixes that day had changed seventeen more lines for the same reason, before the 98, and he had seen neither version of them. Leaving them changed would keep a withdrawn rule alive, so sixteen say again what they first said. The pairs, for him to read: what the bank says again, then in brackets the line that left. Checkers: "Didn't need that?" ("Up and over."); "Too easy." ("Smooth move."); "Didn't see that?" ("Hopscotch champ."). Chess: "Check! Feeling brave?" ("Check! Heads up."); "Was that important?" ("Ooh, shiny."); "Needed that? Oops." ("Pocketed. Oops."); "En passant. Look it up." ("En passant. How chic."); "Thanks for that." ("That'll do nicely."); "Thought you got by?" ("Snapped up in passing."); "Checkmate! Snacks on you." ("Checkmate! Victory snacks."). Dots and Boxes: "Too easy." ("Neat and square."). Reversi: "Try flipping that." ("Flip-proof."); "Did that sting?" ("So many flips!"); "See you behind." ("Front seat, please."). Sea Battle: "Ouch. Not sorry." ("Spot on!"); "And stay down." ("Down to the deep."). One is left for him to choose. Reversi's skipped player first said "Go on, show-off."; the first of those fixes made it "Go on, your go.", and that is one of the 98 old lines he read and kept. It stays "Go on, your go." until he says otherwise.
Would reverse it: only Karim, reading a line or a bank and asking for it to change: he may keep any of the sixteen newer lines he prefers. A change to what a mascot says is shown to him, old beside new, before it lands (Karim's Taste, "The mascot talks to the people at the table").
Decision: two calls, one in the game and one in shared code.
1. Chess is a timed game by default, on a plain clock. Every start sheet, on every kind
of screen (a room, one phone, the hotspot), opens on five minutes each. The host can pick three or ten
instead, and "No clock" is the last choice, on a line of its own under the three lengths. Your time runs only
while it is your move and a move gives nothing back, so "five minutes each" is a game over
in ten at the latest. Running out loses, unless the other player has nothing left to mate with, which is a
draw (unchanged). The lengths are one list in games/chess/game.ts
(CLOCK_MINUTES), with one entry of words each in the game's copy file.
2. A game says when its own clock is the length of the game, and a tournament then keeps
it. There are two kinds of clock, and a tournament had treated them as one. A turn clock
(Checkers' thirty seconds a move) only keeps a put-down phone from stalling a room; every tournament match
already has one from the platform, so the game's own is dropped there. A total clock (so many
minutes each) is what ends the game. A game that has one declares it, rules.totalClock: the
lengths a tournament may play, as minutes each by the answer of its own clock question, and the one it
opens on. For such a game the tournament's start sheet asks the game's own clock question with only those
answers (never "No clock": a round has to end); the server starts every match with the host's answer, or
with the game's usual one for anything else a phone sends; and no player's platform clock runs
while the game's own does, so one clock times a move and never two. The platform still times the
wait between two games of a match, and keeps one backstop (both players' whole clocks and that wait) after
which it would call the match on the score. A phone left face down still loses with nobody tapping: the
game's own deadline is a scheduled job on the server, the same one that ends an ordinary online game on
time. In a best of three every game starts on full clocks and a flag loses that game.
Why: Karim, 2026-10-04: "I think chess needs a timer. We are not professional-level players. No timer is also allowed, but let's encourage timer-only mode. Each player gets some time, and when his turn is running, only the time counts." A game with seconds back for every move has no latest end, and is one more thing to explain to people who are not chess players. And in a tournament the old rule had it backwards for this one game: Chess's own clock was thrown away and the platform's put in its place, which gives a third of itself back every time the turn passes on. Two players who kept moving never ran out, so a chess match had no upper limit and one long match held up the whole round.
Rejected: teaching the platform's clock to be a total clock (a game declares
"no give-back" and the lengths, and the platform runs it). It was the first idea, and it is the same clock
built twice. The platform's clock calls a MATCH, so in a best of three one bank would have to last three
games and a flag in game two would lose the match whatever the score; "nothing left to mate with" is a rule
of chess the platform cannot know without a hook made for it; the board would stop in mid-position instead
of saying how it ended; and the clocks would sit in the bar over the board, without the last ten seconds
turning coral and ticking, while every room shows them at the players' feet. All of that already exists in
the game's own clock, tested, so the block was never the clock: it was the tournament deleting it.
The platform keeping the time and telling the game when it is up: the table refuses a time-up it
did not arm itself, on purpose, so a client can never call time. Two minutes each (Karim said "2 or
3"): a move every three seconds for people who are not strong players; three is the shortest on offer.
Ten minutes as the opening length: twenty minutes of somebody else's game is a long wait in a room.
No backstop for a game on its own clock: it costs nothing and a round can then never wait on a
game whose clock failed to arm. Renaming the stored field a match's clock uses for "the game is
timing this stretch itself" (allAtOnce, named for Sea Battle's fleets): it would need old rows
rewritten for a name.
What it costs: a tournament's bar shows no clocks for Chess, where every other game's does; someone watching a chess match sees the board and not the clocks, as before. A match that was already being played keeps the clock it was stored with (a tournament match on the platform's clock with its give-back; a room's game on the clocks it had, with nothing more given back), and nothing is migrated.
What would reverse it: Karim wanting two minutes, or other lengths (one number in the list and its words); wanting time back per move again (then the game has no latest end, and a tournament needs a different limit); a second game with a total clock wanting it shown in the tournament's bar (then the bar reads the game's clocks, never a second set).
Decision: six leftovers from the review of the entry below. Two of them correct that entry: point 5 there said the lines had been read by a person, and they had not; point 1 there cut an award from the friend who came last.
1. After the wooden spoon, no winner goes up and no award for a win is handed over. The winners are everybody level on the most games won, counted from the places: two friends level on wins at a small table hold no "Trophy hog", and are still who won. The awards for a win are the most wins, a run of wins, the first game, the last one ("Last laugh") and the one game somebody won. An award the spoon's holder shares is never counted and never left off: going up again beside a friend ("Joined at the hip"), the winner included, is a kind close. The ways out are the ones in the entry below, with one change: a full stage leaves off ONE of those awards (a winner's second first), and where others remain the spoon's holder is given a line of their own in its place, so the spoon closes and the rest stay; only where nothing more can be said of the holder are all of them left off, and never below three awards. Why: on 40,000 nights a reviewer made up, 19,497 had a spoon. The friend who came last lost the one award they shared with the winner in 96 of them and was on stage for the spoon only; "Last laugh" came straight after "Fashionably last" in 103; an award for a win in 282; and a friend level on the most wins went up after the spoon in 496. All four are none now. The spoon closes 91.8 shows in a hundred on those nights (it was 89.5), and 93.0 on the committed fuzz (it was 89.4). What it costs: in about 8 shows in a hundred with a spoon one award is left off, nearly always on a full stage of seven (1,637 of 19,497; it was 1,523 before this change). Rejected: reading the winner off "Trophy hog" (it has no holder when two are level); leaving off every such award on a full stage (it took two where one makes the room). What cannot be checked from a night's awards: that nothing was left off wrongly, because that is about an award that is not there. It is held by three nights written out by hand, and by counting that the fuzz still ends some nights on a shared award after the spoon. Would reverse it: Karim wanting "Last laugh" as the night's last word whatever came before it.
2. The list of lines says only what it does. This corrects point 5 of the entry below,
and "every line has been read by somebody" in the heading of the one under it.
mascotLines.pinned.json (it was called "reviewed") holds every line of every bank, and the banks
must match it: so a new or changed line fails the test until the list is changed in the same commit, and
then it is in that commit's diff. That is all it does. The lines on it were written out from the banks in
bulk, and Karim has not read them yet; his reading is the check on tone, and it is still
to come. Why it matters: a test header that says "read by a person" is believed by the
next person, who then does not read.
3. The list covers the banks, not every line a mascot says. A bank is a numbered list
of text, or the lines filed by name under said or say. A line filed under a name
of its own anywhere else (Tambola's "Eyes down!", the couch on the home, the host where a room is opened,
the mascot on a lost page) is neither pinned nor tripwired, and the test now says so and holds itself to
it. Two holes that were cheap to close are closed: a list called steps is a how-to only where
a how-to is filed, and a numbered list that mixes text with something else fails.
Rejected: finding single lines by reading which screens hand copy to a mascot. Nothing in
a copy file says "a mascot says this", so the finder would be a guess that goes stale; a narrow claim that
is true beats a wide one that is not.
4. A save is on trial only until it has worked, and a fault after that deletes nothing. A save that was read back is proven once its board has been drawn once and its clock has ticked once (with the board drawn, the screen's net also stands down when no clock is running or somebody has moved). Until then a screen that cannot be drawn, or a tick the rules cannot take, forgets the MATCH with the one calm line. After that, and in every match started here and now, a tick the rules cannot take deletes nothing: the clock stops where it stands, the player sees the same card as for any fault, and "Try again" leads back through Resume. And no fault of one match takes what a party has recorded: the party is back at "next up" for that game, the story it already tells when its match could not be read. Why: the clock's net had been up for every match for its whole life, and took the party with it: a rules bug on a timeout in the fifth game of a party forgot the night and its awards with "Couldn't pick that one up. Starting fresh." The screen's net stayed up until somebody moved, board drawn or not, so an ordinary fault of the screen deleted a healthy match. Rejected: letting the timer throw once a save is proven (on a phone a throw from a timer can close the app); skipping the game when its match is forgotten (the party would record a game nobody chose to pass over). Would reverse it: a rules bug that faults on every tick of a game, where "Try again" could only loop; today the second try is by then a save that could not be picked up, and the match goes.
5. An old number seed is walked on every kind of row. The search for a leaked seed was only ever run with a number seed on the tournament's row; a match's row and a party's row are now walked on one too, from the lobby, in a tournament and through a party to its awards. Three planted leaks of a number seed that stayed green fail now. The check's header names what it still does not find (a seed inside the refusal of a mutation no test makes refuse, a leak shown only to a third seat, the HTTP routes, a function file in a hidden folder): known limits, not chased.
6. No line twice across the tournament's winning banks. "Called it." was in three of them and "Smooth as butter." in two; each is in one now ("Feeling lucky today.", "Best day ever!", "Big grin over here." took the other places). Awaiting his review with the rest.
Decision: seven leftovers from the review that judged the branch safe to merge. Each is fixed at the cause, with a test that failed first.
1. The wooden spoon is never followed by a second trophy for the night's winner. The winner is whoever won the most games. When they hold two awards, the spoon's holder is first given something that is plainly true of them ("Never missed a game"): the spoon is then their second, it moves to the round of seconds, and it closes the show after both of the winner's. Where nothing more can be said of them (the spoon is shared, they hold two already, the stage is full) the winner's second is left off, as long as three awards stay. And in its own round the spoon is always handed over last. Why: with everybody's first before anybody's second, "Fashionably last" was followed by the winner up again for "Trophy hog. Sorry. Not sorry.", which reads as rubbing it in; the spoon closed 71 shows in a hundred that it was in. It now closes 89 in a hundred (and always at a table of two: every night of two friends over three games is tried), and the winner is never called again after it. Rejected: moving the spoon after the winner's second with nothing else changed (the winner would be handed two before the spoon's holder had one, which breaks the order decided below); always dropping the winner's second (a table of two would fall to two awards, and a stage is three or none). What is left: in about one show in ten with a spoon, a second for somebody who is NOT the winner is still handed over after it. Widening the rule to any second is one line. Would reverse it: Karim wanting the winner's second kept on a full stage.
2. A save's first board is part of picking it up. In a game that opens on "Pass the phone", the tap "I'm Ana, let's go!" is itself a move, so the safety net had stood down before the board was first drawn. A save now stays "what is on screen" until a move is made AND its board has been drawn once. The play screen reads everything it needs from a match through one selector, and that selector is what a save is put through as it is read, for every seat, and again on the match that taking the phone leaves; the first tick of the clock is tried too. A tick the rules cannot take is caught in the timer, and the match is forgotten with the one calm line. Why: 187 of 2,001 damaged saves were kept and then threw (the score line in the header, the tick that taking the phone starts). Rejected: a list of the screen's reads kept by hand in the check (it was already one read short); leaving a timer's throw to the platform (on a phone it can close the app).
3. One rule for what a seed is, at every door. A programme used to keep any whole number as its low 32 bits (-1 as 4294967295) while a stored row holding -1 was refused. Both now ask the one question: 64 lower-case hex characters, or a whole number from 0 to 4294967295. This reverses the last sentence of point 6 in the entry below. Why: no programme was ever started with a number out of range, so the cut protected nothing and made two answers to one question.
4. Every public function of the backend is accounted for by name in the seed check. It is asked, or called and searched, or on a list of the ones that declare an answer with no room for a seed (checked against what each declares), or one of the sign-in library's two. One in no list fails. The list is read from every kind of file Convex loads (its own list of extensions, read from the package). The search finds a seed in any case, in pieces of sixteen characters or more, as any 64 hex characters, as one of its 32-bit words, and an old number seed written out. And each question is also asked where it has something to say. Rejected: claiming the search finds a seed however it is coded (it cannot; the header says what it does not find, and what stands against those instead).
5. The line test says what it is. Every line of every bank, in every game and every shared file, is on a pinned list, however its bank is written, so a new or changed line shows up in a commit's diff. (This point first said the list was "the check on tone, and it is a person": it is not. The list was written out in bulk and Karim has not read it yet; see point 2 of the entry above.) The patterns are a tripwire: of thirty-two fresh poking lines they caught three, and the test holds itself to that number so nobody reads a green run as "the lines are kind". Thirteen lines of the tournament's banks and ten of the party's awards that spoke to or about another player were replaced, and two near-repeats. Not changed: the games for a whole room, which he approved game by game; a few of their lines tease one friend by name, and that is his to rule on.
Would reverse it: a kind of save that should be kept though its board cannot be drawn (none known); a seed form added to the engine (then every door takes it at once, through the one rule).
Decision: ten calls from a review that judged the branch safe and still found these. Each is fixed at the cause, with a test that failed first.
1. Everybody's first award is handed over before anybody's second, in the order the room sees. The show is two rounds: the round of firsts, then the round of seconds, each in the order of award kinds he approved (the true-of-you ones warm up, a game's own and the places take turns, the wooden spoon last of its round). A friend whose two awards are both their own goes up first for the earlier kind, so the wooden spoon or the last laugh still closes the night wherever the rule lets it. Why: in more than half the nights with a second award, somebody was called twice before a friend was called once (Ana, Ana, then Cy, Ben, Ben, Di). This reverses "the promise is about who gets one, not the order" in the entry below. Rejected: keeping the wooden spoon as the very last award always (it is often somebody's first, and then a second would have to come before it).
2. The same award name goes up twice at the most. A friend who is waiting gets, of the lines that are true of them: one about how they played before one that only says they were there; among those, a name not on the stage yet before one that is; then their best. When every line of a friend's is a name already up twice, a friend placed before them moves over to another line of their own if they have one; if nobody can, that friend is not called up and keeps their own award (and then nobody goes up twice). Why: one night in five staged the same name three times or more, one in thirteen four or more: a ceremony of "Had a moment" four times is dull. Rejected: "a name nobody has used yet, always" over a friend's better line (it hands "Never missed a game" to a friend who won a game); letting a third copy go up to keep everybody on the stage (his call: variety first).
3. A real night is two people who were each IN two games, one that stopped short included. The games that count for "of N games" are the games that count for a real night. Three friends, two games, the second stopped short: all three are now called (the opener, "Had a moment" for second place, "Played all 2 games"), where the stage had one award for one person. This replaces "two people who each FINISHED two games" in the entry below, which was called deliberate there.
4. A stage is three to seven awards, or none; never one or two, and never one that could only say who was there. A night that cannot make that has no award-by-award show: the curtain opens on "A quiet one. Everybody gets a cup." over a table with everybody's own cup on it, then the photo, where everybody holds theirs. That is a night of one game (unless the game itself gave three), and a night where no game ran its course. So a real night has a stage exactly when one of its games ran its course. Rejected: a stage of two ("Never missed a game" twice), and three copies of one was-there award: neither is a ceremony.
5. Every number of every award is counted twice, two different ways. The fuzz already asked whether each award was deserved; a second reading of the night, written another way, now works out what each award has to carry (its numbers, the places it reads out, the game it names) and the award must carry exactly that: nothing off, nothing missing, nothing extra. The rivals' numbers and the early and late counts are in it.
6. A seed is 64 lower-case hex characters, or an old number as every one was minted (a whole number from 0 to 4294967295), and nothing else is ever read as one. The rules refuse anything else in words, the server checks a seed where the row is written, and a version (a move count) must be a whole number, 0 or more. Why: the schema can only say "text or a number", so a damaged seed would have made every later move a server error; a seed of NaN quietly drew what seed 0 draws; a version of 1.5 would have drawn what version 1 draws. Rejected: reading a number outside the old range as its low 32 bits in a match (it was pinned that way a few hours earlier; no such seed was ever minted, and a damaged one should not be drawn from). A programme still takes a whole number that way when it STARTS, as it always did.
7. The "no answer carries a seed" test reads every folder and every answer. It finds public functions in any folder of the backend, and besides every question it calls every mutation and action whose declared answer could hold a value (text, a number, an object), refused and answered. The auth library's sign-in and sign-out are named as not ours.
8. A save is kept only if everything the shell does with it on the way back in can be done. The shell's own steps are taken while the save is read, with the shell's own functions: who holds the phone, the board's view for every seat, the result, the clock, the next draw, the hand-off, what taking the phone starts and a counting clock's next tick (both tried on the side); for a party its resume card, its "next up" line and its awards at any stage. Anything that throws means the save is forgotten and the table kept. Behind that, the tap on Resume cannot throw, and a screen that cannot be drawn from a save that was just picked up forgets it and opens the set-up room with one line ("Couldn't pick that one up. Starting fresh."). Rejected: checking the shape of every game's state (a second copy of every game); forgetting on any later fault (a board that did not download would wipe a match).
9. Tone needs a person, and the test says so. Every line of every bank in a game for two is in a reviewed list; a new or changed line fails until it is added there, where it is read as a diff. The word patterns stay as a tight net for the usual shapes. Why: the reviewer made up thirty-two lines; the word list let twenty-eight poking ones through ("Nice try.", "They never saw it.") and refused two harmless brags ("Stop me!", "Try me.").
10. A bank says one idea once. Twenty-three first-pass lines repeated an idea already in their bank ("Box spotted!" beside "Spotted it!") and four lines still poked ("Checkmate! See that?"). All replaced; the list for his veto is the same file as before.
Would reverse it: two friends of six left off a stage on a team night reading as unkind to Karim (then a third copy of a name is allowed before anybody is left out); a one-game night wanting its one or two awards on stage (then the floor of three applies to real nights only); a save being forgotten that a player could have used (then that step leaves the guard).
Decision: six calls from a review that fuzzed 280,000 nights. No award said a false number anywhere; the faults were all in who was called up.
1. A true-of-you award goes up once for each friend it is said of. "Had a moment" for Ben ("2nd in Snakes") and again for Cy ("2nd in Ludo") is two awards, each with its own line; every other award still goes up once. Each friend gets the best thing that is true of them, whoever else has the same name on their cup. Why: one name per stage kept a friend who played every game off a stage that had room, while the winner went up twice (four friends, two games, nobody level: one night in forty at a table of seven or fewer), and it left team nights with two awards. Rejected: picking a name nobody has used yet first (a friend who came second is handed "Never missed a game" only because somebody else already "had a moment").
2. Nobody goes up twice while a friend who played has not gone up. No exceptions: a friend who came for one game and lost it is called up as "Best guest" before the winner's second, which reverses "came to the party only on a bare stage" from the entry below. On a night too short for a ceremony nothing is borrowed, so a second simply waits. (What a night too short is, and the order of the show, were both decided again the same day: the entry above. A real night is two people who were each in two games, and everybody's first is handed over before anybody's second.)
3. Two different groups. The stage is for whoever was in a game. A "most" is measured among people with a finish that counts, and two level at the top may share only in a group of five of those. Why: three friends walking in during the last game turned two players into "a group of five", who then shared most wins and most last places and one went up three times.
4. A count and "the same place every time" are one story. One is kept, the one that says more: "Second in 3 of 3 games" covers every game and is the bigger trophy; "Second in 2 of 3" (the third was a duel, where second is last) is the weaker line and "2nd in all 3 games" stays instead. Wins always keep the count, because the games it leaves out are ones everybody drew.
5. A game that was called off with no places is still a game of whoever stayed at it. A room now stores who stayed, level and marked as stopped short, which is exactly what a duel that stops itself already gives on one phone. So "Won 2 of 4" and "Played all 4" count the same four in both. Rejected: counting only games that ran their course (the night's own "game 4 of 4" would then disagree with "of 2 games"); counting it for the friend who walked out as well (a duel that stops itself does not).
6. The guarantees are written down and fuzzed. Nobody more than two; nobody two while
someone who played has none; one award of their own for everybody; and how many go up, in what order and
under which names, as the entry above settles them. The reasoning is beside the code
(programme/awards.ts) and fuzz.awards.test.ts plays about three thousand
nights against it on every run.
Would reverse it: four "Had a moment" in a row reading as a dull show to Karim (a night of only team games gives every friend the same line): then the fill varies the name or stops early. Or him wanting everyone called once before anyone is called twice in the ORDER of the show: that is one function, and it moves the wooden spoon off the end.
Decision: four calls about what one shared phone keeps between two launches (the half-played match, the party it belongs to, and the table: the friends' names and the colour each one wears). (1) The save carries a version number, and it moves whenever a saved match can no longer be read the way it was written. It is 2 since Sea Battle's state gained its placing limit. (2) A save from another version keeps the table and forgets the match and the party. Nothing is converted. (3) A save of the current version is still checked when it is read: the match needs a game this build has, at least one player, and a state its own game can read; the party has to be party-shaped. Whatever fails is forgotten the same way, and a party that cannot be read takes the game it was on with it. (4) Reading always finishes. The storage never throws, a save that is cut short reads as nothing saved, and so does a phone whose storage refuses (a private window). The player lands in the ordinary set-up room with the same people on the sofa. Built 2026-10-04, awaiting Karim's review.
Why: a match is saved with a game's own state inside it, and a game changes that shape now and then. Sea Battle did, the number did not move, and a match saved the day before was handed to a board that read a field it did not have. Nobody outside the project plays yet, so a half-played match lost once per release costs nothing, and code that converts old states would have to be written and kept by every game for every shape it ever had. The table is the one thing a group would miss: retyping five names, and Eve turning from purple to green. The number alone is not enough, which is why (3) and (4) exist: a write can be cut short, a game can change shape with nobody remembering the number, and a save that threw on the way in left the screen loading for good (learnings, 2026-10-04).
Rejected: converting old saves, game by game: a converter per game per shape, for matches nobody has. A version number per game: it would forget fewer matches (only the changed game's), at the price of a number for every game to remember instead of one. Mending a broken save: guessing at a game's state is how a board gets drawn wrong. A "something went wrong, clear your data" screen: it asks the player to fix our fault, and the room is where they wanted to be. A time limit on the loading screen: the room would open and then the late read would replace whatever had been started in it, and it hides the cause. Keeping an unreadable match and offering only "Start fresh": a card that names a game nobody can go back to. Keeping the game of a party that could not be read: it would come back as a match of its own, in a party's short version, with no night around it.
What would reverse it: real players with long matches worth carrying across an update (then a game like Chess brings its own converter and the number becomes the game's); saves that follow a player to another phone (then a save is shared data, and forgetting it is no longer cheap).
Decision: the two cards of a miss are held up by the platform's timer, as before. Once their time is over, EVERY way into the rules finds them turned back: the timer's own tick however late it comes, the next card played, a walk-out, and any touch from either player, even one the rules would otherwise refuse (a card from the player who is not up, a card that cannot be turned, a table decision out of place). For such a touch the turning back is the whole move: the tapped card is not played. Someone only watching is refused as always. The board follows: one second past their time, the cards take a tap on both players' screens. Built 2026-10-04, awaiting Karim's review.
Why: the first fix let only the next player's card turn them back, and said "never by a refused action". With the timer lost and the next player away, the player who had just missed sat tapping a table that refused every touch, and the away player's turn clock never started, because it starts when the cards go down. A turn clock exists so a put-down phone cannot stall the room. Now the player who is there clears the table, the away player's clock starts, and it plays their cards for them when it runs out.
Rejected: a second timer as a backup: a match has one deadline, and a second one is a change to every engine for one game. Letting a phone send the tick: the tick is the platform's own action, and a phone that could send it could end a turn. No timer at all, each phone turning the cards back by its own clock: it would make a lost timer impossible, and it was weighed seriously, but it undoes the earlier call that the pictures stop being sent the moment the cards go down, and it means rebuilding how the board plays a miss. Letting a watcher clear the table: someone with no seat never changes a match.
What would reverse it: the platform learning a second deadline (then the turn clock runs beside the stretch the cards are up, and its own tick clears them with nobody touching anything); a redesign of the miss where each phone turns the cards back itself.
Decision: five calls, made together.
Math.random(). The rules stay pure: the seed is handed in.| Mode | Who runs the match | Where the seed's chance comes from | Who must not learn it |
|---|---|---|---|
| Online | The server | crypto.getRandomValues inside the mutation that starts the match (convex/lib/seed.ts). Convex fills it from a generator it seeds with 32 fresh bytes for each run of a function | Every phone. No answer the server gives carries it |
| Hotspot (paused) | The host's phone | The phone's operating system, through expo-crypto (~/modes/freshSeed) | Every guest. The host sends it to nobody |
| One phone | The phone itself | The same call: the phone's operating system, or the browser's crypto on the web | Nobody: nothing is hidden from the device that runs the match |
The same seed gives the same match in all three, because the rules file and the generator are the same files everywhere and the generator is whole-number arithmetic only.
Why: a player is shown some of a match's draws: their own fleet in Sea Battle, every card turned over in Pairs, their own word in Undercover. The seed was 32 bits and so was the generator's whole state. A review script took one player's own fleet, tried every seed in about 75 seconds on one core and laid out the rival's hidden fleet. The same search reads the Pairs deck. Tournaments make that worth somebody's minute. A wide seed alone would not have been enough with the old generator, which keeps only 32 bits whatever it is given, so the two change together.
What it was checked against. Someone who watches a long run of draws (Tambola calls ninety numbers out loud; Pairs ends with the whole deck face up): ChaCha20 is built so that no amount of its output gives away the key or the rest of the stream, so this does not have to be argued game by game. A match in progress when the backend is deployed: its number seed takes the old path, which is held to the old output bit for bit by a test written before the change. On the dev backend a match was started on the old code, the new code was pushed, and its next four rolls were the ones its number gives. The same result on every device: the block is checked against the RFC's own published block and against Node's own ChaCha20, and matches started on the dev backend rolled what the same seed rolls on a laptop. Cost: opening a stream and drawing takes about 1.5 millionths of a second, shuffling ninety numbers about 6; a move on the server takes thousands of times longer for other reasons. An older app talking to the new backend: online, a phone never draws from the seed, so there is nothing to disagree about. Ten times the players: 64 characters per match row.
What we rejected: a bigger ordinary generator (xoshiro128**, sfc32): 128 bits stops the search, but those are not built to resist someone solving for the state from many draws, and some games show many. A separate hidden stream per player: every game's rules would have to say whose stream each draw comes from, a change to all of them, and it does nothing about a small seed. Keeping 32 bits and limiting requests: the search runs on the attacker's own machine and asks the server nothing. A deal step only the server runs: one phone and a hotspot have no server, and the rule that one rules file runs everywhere would end. Working each match's seed out from the tournament's: fresh chance costs nothing and cannot tie two matches together. Rewriting stored seeds: a match half played would deal differently from the one that was started.
Still open: a tournament's or party's own draws (who meets whom, the free pass, the
coin after two draws, the lot that settles a tie) are still made from a 32-bit number. It stays on the
server and is blanked in what phones are sent, but the pairings a player sees could be searched back to it,
and the coin read before choosing to play for a draw. No match's hidden things depend on it. Closing it is
a small change inside the shared tournament and party rules (the seed's type in
packages/engine/src/programme, two places that cut it to 32 bits) and the two places that mint
it; it was left because those rules were being changed by other work on the same day.
Closed the same day: a tournament's and a party's own seed is wide too, minted from the same places as a match's (the server online, the phone for a one-phone party) and kept whole in the programme's own record. Every draw it makes (the pairs, the free pass, the coin, the lot between equals, the best fan between two level fans, a shuffled running order) opens a ChaCha20 stream of its own, named by what the draw is for and its round, so a coin tossed in one round moves nothing in the next. This matters more than it sounds: round one's pairs are shown to the whole room, and with 32 bits a player could try every seed on their own machine, keep the one that draws those pairs, and read who they meet next and what the coin will say before choosing to play for a draw. A programme that was already running on a number keeps that number and draws exactly what it would have (its draws were written down from the rules before the change, and are compared whole). On one phone the awards pick which line a winner says with a plain number; that number now comes from a stream of its own and never from the seed's own characters. A saved party whose seed is neither form is forgotten when the phone reads it back, the same as a saved match. Rejected: taking that line number from the seed's first characters, which is less code and puts part of the seed on show.
What would reverse it: a device with no strong generator to ask (then the hotspot mode cannot keep a guest out of the host's hand and should say so, not fall back quietly); a measured cost on a path that matters, which today is nowhere near.
Nothing changes for a game: it still calls ctx.rng() and still never sees a seed.
What the bug taught: learnings, 2026-10-04.
Decision: seven calls from one review that played a whole one-phone party and probed the room's side.
1. Nobody holds more than two of the stage's awards, and everyone gets one before anyone gets a second. The stage fills in this order: one award each from what the night earned; then whoever still has none gets one (something else they earned, else what is plainly true of them, picked in the seed's own order so every phone agrees); only then a second for anyone; never a third. "Came to the party" is said up there only when nothing else is left to say, and only of someone who played. Why: in the played night one friend held three of four awards. A second trophy for the same person tells the table less than a first for somebody else. Rejected: a softer cap that lifts once everyone has one (a table of two then gives one person four); leaving the true-of-you awards for a bare stage only (it kept three friends off a stage that had room). Would reverse it: a night where a plain "Never missed a game" on stage reads as filler to Karim: then the fill stops at whoever earned something.
2. A first place shared above others is a win, and the line says so. It counts in "Won 2 of 3 games, 1 shared", the way the winner screen counted it. The awards about ONE win (the one-hit wonder, the first and last game, a run of wins) need a win that was theirs alone. A game everybody finished level is nobody's win, as before. Why: a side of three won Undercover and one of them was then called a one-hit wonder. Rejected: counting only outright wins everywhere (a team game would then never count for "most wins", and the winner screen had called them winners).
3. "Of N games" is every game they were in, in every line. A game that stopped early is one of them, in "Won 2 of 4" and in "Played all 4" alike; "in all N games" (the same place every time, the top half every time) is simply not said when one of the N has no place to read. Rejected: leaving such a game out of both: the header had called it "game 3 of 4" while it was played.
4. A twist has to be seen in the places read out. A comeback and a late bloom need every later place better than every earlier one, on top of the averages they always needed. "2nd and 6th. Then 2nd and 2nd." was a better average and no twist. The one-hit wonder reads "Won game 3. The rest: 5th and 4th.", because its other games may have come before the win. "Joined at the hip" counts only games three or more finished: two people on their own are always side by side.
5. A night where nothing was played has no awards show. Every game skipped, or none left that fits: the room is back on every phone, the plate reads "Nothing got played" over "Another party?", and whoever runs the room has the photo's own two ways on. The server books no show for it. Rejected: sending the room straight to its lobby with a line said once: the line would need a flag carried across two screens and a phone that reloads would miss it. As built it is a state the party is in, so a reload lands on it.
6. On one phone a colour is the friend's, not their place's. Whoever sits down takes the first colour nobody is wearing and keeps it: in a game, in a party, in the room between two, and across a reload (the phone remembers the colours beside the names). Why: Eve joined a party as the fifth colour, Dee went home, and "Another party" re-dealt Eve the fourth. A room on many phones already works this way. Rejected: keeping "the colour of your place" and re-dealing inside the party when someone leaves: a friend changing colour mid-night is the worse fault.
7. Past a small table the group photo is a crowd, in rows that never overlap. Each row is as tall as its mascots and the tag under them (the name over the award's name on two lines), four to a row on a phone as everywhere a phone shows a big table. A crowd too tall for the stage scrolls. The shared picture does the same with a plaque checked to fit every award name whole. Rejected: a list beside the group (two things to read, and no room for it on a phone); shrinking everyone until they fit (twenty were initials with no names). Would reverse it: Karim wanting the photo to stay one tight group at any size, with who got what on a second screen.
Smaller, with them: Resume leaves a one-phone clock stopped when the match opens on "Pass the phone" (the tap that takes the phone starts it); removing a friend between two games of a party asks first, in both shells; a game that changes "next up" while the last board is still showing starts no earlier than the start that was planned; a host's answers must be plain answers, weighed in bytes.
Decision: three calls from one review of the new games, each of them a question the platform used to answer by guessing.
1. A player's tournament clock runs only while it is their turn, and a stretch both players are
in at once is the game's own to time. A game says "both of us are at it right now" with one word
(rules.allAtOnce; Sea Battle while the fleets are hidden). Through that stretch no player's
clock runs and nobody is charged. The game ends it itself, against its own limit (Sea Battle's 45 seconds,
which now runs at every table where each player holds a phone, whatever the settings said). When it ends,
the first turn starts on the clocks exactly as they stood. The platform keeps only a backstop one full
clock long, after which the match is called on the score, for a game that named the stretch and forgot to
end it; a check over every two-player game fails a game that does.
Why: the old rule had the game name ONE player as "holding the turn" while both hid their fleets, and ran that player's clock as the limit. Whoever said Ready last, if they were the opener, began the battle on what was left of it and could lose on time at the first shot. It also charged one player for a wait both were causing.
What we rejected: filling both clocks again when the battle starts. It fixes the first round and breaks the rest: a best of three would hand out a fresh clock every round in Sea Battle and in no other game, and the placing itself could still run a tired clock out. Also rejected: leaving the turn with nobody (the wait between rounds), because that wait is 30 seconds and is called on the score.
What would reverse it: a game where the two players really do race each other inside such a stretch (first to finish wins something) needs a clock each in it, and that is a different rule.
2. On one shared phone the shell asks the game whether this beat is taken in private
(rules.needsHandOff), instead of asking for the phone whenever the turn's seat is not the one
it remembers. Sea Battle answers yes only while a fleet is being hidden, Undercover only during the deal.
A game that says nothing is private at every turn, as before.
Why: who took the phone last is the screen's own memory and a reload wipes it. Both games leave their turn resting on the last seat that took the phone in private and play on in the open. After a reload the shell asked for the phone for that resting seat ("only Ben sees what's next") on somebody else's turn, with nothing private to come.
What we rejected: saving who took the phone (a phone that locks mid-placing must come back shielded, so the memory is right to forget in a private beat and wrong only in an open one, which is the very thing the shell cannot tell). And working it out by comparing what the seat is sent with what a watcher is sent: Undercover sends a seat its own word all game, though the shared screen never draws it, and Word Search's hand-off starts a clock with nothing hidden at all. Only the game knows.
What would reverse it: nothing foreseen; a third game with a private beat and an open one answers the same question.
3. A match writes down the colour each seat PLAYED in, as it starts, and a result reads it straight off. A room past twelve deals colours round again, and a match hands the seats that collide a free colour each, in the order people joined the room.
Why: the result used to make that deal again from the seats it had, guessing where a leaver had stood in the room's list. The guess was right only while the seats followed the room's order; a host who picked seats in another order got a player who stayed drawn in the leaver's colour (one shuffled table in five in the reviewer's run).
What we rejected: a cleverer guess. Where a leaver stood is simply not known later, so the fact is written down when it is known. It costs one read of the room's own members as a match starts (bounded by the room's size) and no extra document.
Also settled here: the rules themselves refuse a "time is up" tick that comes before the deadline (every table game, through the one shared guard), and every dispatcher holds an early tick and arms it again, so a refusal can never leave a clock dead. Sea Battle's sea takes the page's gutter down to a hairline on a 360 phone, because a shot cannot be taken back and a near miss cannot be guessed at (below). The stage's speech bubble takes the room the mascots leave rather than pushing one of them off the screen. Karim has not seen any of these yet.
Decision: four calls, made together because one review raised them together. (1) The server counts "next up" from the end of the finished board's moment. After a party game ENDS, the next game starts thirteen seconds later (five for the board, eight for "next up"); after a skip, a refusal or the party's start there is no board, and it is eight. Every phone keeps the finished board up for those five seconds (longer when the game's own ending runs longer), so the countdown it then opens on is whole. One shared phone holds the board the same five seconds and counts the same eight. (2) A guest's phone keeps the end of the night for itself. Once the show is at the group photo, each guest's phone holds what the party stored (awards, who was there) in memory. When the host takes the room back, or closes it, a guest still looking at their card or the photo keeps it until they say so; the moment the room starts something else it is put away. The server is unchanged: "Back to the room" still returns the room to its lobby at once. (3) What just happened is read off the party's own state in both shells. One shared phone used to keep "the game that was skipped" as a field of its own; it now reads it the way a room does, and a game that refuses to start is recorded as the game's refusal in both. (4) A party left short by a game going out stays on the TV. When a friend coming or going puts a ticked game out and leaves fewer games than a party needs, the room keeps showing the night, says which game is out, and asks for another, with Start waiting.
Why: (1) the server cannot know how long a game's ending runs on a phone (that is the board's own business), so it cannot wait for "the board is done"; a fixed moment is the only thing every phone and the server can agree on, and counting the pause from its end is what makes the countdown whole. (2) the host's tap is about the room, and a guest's card is theirs; holding the stored outcome on the phone means the photo no longer needs the party to be live, with no new table, no new query and nothing to clean up. (3) two shells keeping the same fact two ways is how one of them said "Skipping X." for a game nobody skipped. (4) the alternative was the whole party vanishing from the room because one game stopped fitting, with nothing on screen to say why.
What we rejected: for (1), the host's phone telling the server "the board has been shown" (a dead host phone would hold the night, the fault skip was added to cure), and lengthening every "next up" to thirteen seconds (the first game and a skip would wait for a board that is not there). For (2), keeping the room on the result until every guest has dismissed it (one forgotten phone would hold the host's room), and a server-side "the party is over, read its outcome by id" query for guests (a second way to read a programme, for a screen that only needs what the phone already holds). For (4), keeping the dropped game in the count, or letting two games be a party when three no longer fit.
What would reverse it: a game whose ending regularly runs past five seconds makes its
"next up" appear part-spent again: lengthen the moment, or let a game declare it. A guest who reloads loses
the kept photo (it is in memory); if that matters, persist it on the device. Tests:
convex/programmes.party.test.ts ("the next up moment"),
modes/online/party/keepsake.test.ts, modes/local/party/partyRun.test.ts,
modes/online/party/partyStage.test.ts. Built overnight 2026-10-04, awaiting Karim's review.
Decision: five calls, all from an independent review of the first build. (1) A tap counts only on a screen that has kept up (the rule and its numbers are in learnings, 2026-10-04). (2) A mascot speaks on five moves only: the last pair, the third pair or more in one turn, a pair that takes the lead from level, a pair on the first try (neither card ever turned up), and a slip (a miss on two cards that had both been turned up, while there were still cards nobody had seen). A plain pair keeps its hop and says nothing. (3) A found card shows its owner twice: a wash made for the card's cream paper (one table for both looks) and a corner tab, top for the first player and bottom for the second. (4) A miss whose timer never comes ends itself: once its time is over, the next card played turns the two back first. (5) One mascot among the pictures, not three; a star and a moon took the other two places. Built 2026-10-04, awaiting Karim's review.
Why: (2) Every pair spoke, and a quarter of the misses: fifteen lines a game or more, which is noise. Now it is about five. (3) The paper is cream in the dark too, so a wash mixed for the dark ground was wrong on it: silver vanished, yellow and gold matched. Yellow is the first seat on every shared phone. Position does not depend on telling two colours apart. (4) The rules refused every card until the tick, so a lost timer froze the game with "That move won't work here." (5) Three pictures shared one outline and differed by colour, and the blue one sat on a blue player's wash.
Rejected: a floor on how long a miss is shown (the first fix: it made the
screen fall behind faster). Remembering an early tap and playing it later: the player would be
acting on a card they had not seen. Taking a tap during a pair's pop: instead the pop plays on
after its move, so the rule stays one sentence. A groan when "the second card's twin had been
seen", which the review asked for: it is worked out from a face-down card's picture, so it is a hint,
and the view would no longer be the same whatever lies under the cards (the test in
rules.test.ts would fail, rightly). The slip in (2) says nearly the same thing from what the
room watched. The finder's small mascot head on each card: thirty heads on one table, unreadable at
fifteen pixels. Turning the two back on the tap and asking for a second tap: a tap that does
nothing visible to its card.
What would reverse it: Karim finding the lines too few or the tab too small; a card whose paper follows the look (then the app's own washes apply again); the shells learning to wait for a board to finish showing (then the hold on a finished table needs no bound).
Decision: Pairs (apps/app/src/games/memory/, the memory game) ships with five
calls of its own. (1) Two seats, a duel on the shared table, although the box game seats up to
six. (2) An odd number of pairs on every table: fifteen (thirty cards) for the full game, nine
(eighteen) for the quick one and the party's short version, so a finished table is never level.
(3) The two cards of a miss are held up by the platform's timer (deadlineAt, 1.4
seconds), not by a tap and not by a timeout on the phone. (4) The miss itself passes the turn;
the next player simply cannot turn a card until the timer has turned the two back. (5) The picture
under a face-down card never leaves the server, for players and watchers alike, and nothing a phone is
sent is worked out from one. Built overnight 2026-10-04, awaiting Karim's review.
Why: (1) The table still knows two kinds of seats, a duel on the clock or an open table where everyone moves at once; turns going round three or four is neither (the same finding as Dots and Boxes below). A tournament pairs two, and two is what keeps (2) true. (2) A tournament settles a second draw by chance, which is the worst way for a memory game to end; with an odd count between two players it cannot happen, and no tie-break has to be invented. (3) Every phone has to show the two cards for the same stretch, and nobody may turn a third meanwhile: only a time written in the state does both. It also means the pictures stop being sent the moment the cards turn back, instead of sitting on the wire until somebody taps. (4) In a tournament each player has a clock that gets time back only when their OWN move ends their turn. A turn that ended on the timer's tick would give nothing back, and a player who simply kept playing would run out of clock. Passing the turn in the move costs the next player the second and a half the cards are up, out of the eighteen seconds they get back. (5) A memory game where a phone holds the answers is not a game; and a line such as "you had seen its twin" is the same leak in words.
Rejected: three to six players outside the table kit: a second set of rounds, seats and leaving rules for one game. An even table with a tie-break (the last pair counts double, the opener loses): rules nobody at a party knows. The cards stay up until the next player taps: the classic app answer, but it leaves two pictures on every phone for as long as someone dawdles, and on separate phones the room waits on one person to notice. A timeout on each phone: phones would disagree by their own lag. Keeping the turn until the cards turn back: see (4). A turn clock that only passes the turn: passing reveals nothing, so stalling for it would be worth it; the clock turns cards instead. A near-miss line for "its twin had been seen": it reads a hidden picture.
What would reverse it: the table kit learning turns that go round (then Pairs seats up to
six in a room and stays a duel in a tournament); Karim finding a second and a half too long or too short (one
number, MISS_UP_MS); the platform's clock learning to give time back on a system tick (then the
turn could pass when the cards turn back, which is how it looks on screen already).
Decision: a move can reach the rules a moment after its deadline and before the platform's own timeout does. What happens to it depends on what the clock is FOR, and there are two kinds. A turn clock (Checkers, Dots and Boxes, Sea Battle, Reversi: so many seconds a move) only takes the choice away: when it runs out, a random legal move is played for the player. A move of their own that gets there first is played as their own move, in place of the random one. It is not a missed turn, and like any move of their own it ends their run of missed turns; the next turn's clock starts from when it was played. A total clock (Chess's time each, and the tournament's own clock for a match) is the result itself: once the flag is down a move is not played, whoever gets there first, and the answer is time up.
Why: on a turn clock the timeout exists so a dead phone cannot hold a room, and the run of missed turns (three in a row loses the round, where a game has one) counts a player who is not there. A move, however late, is a player who is there, and it costs the other player nothing they were owed: the window is the fraction of a second the platform's timer takes to fire. On a total clock the time IS what is being played for, so a late move played would be time handed out for free (Chess once even paid its bonus on one).
What we rejected: one rule for both. Refusing a late move on a turn clock would answer a tap that looked on time with "Time's up!" and then play a random move over it. Playing a late move on a total clock is the fault Chess shipped with. Also rejected: counting a late-but-played move as a miss, or playing it without ending the run: both punish a player the rule exists to protect, and need the rules to keep a second idea of "late" beside the one the platform's timer already is.
What would reverse it: a turn clock that starts to matter as a result (points for
speed, a penalty for running out) becomes a total clock for this rule. Each of the four games has the test
in its own rules.test.ts ("arrives after the clock ran out"); Chess and the tournament clock
have theirs.
Decision: Reversi (apps/app/src/games/reversi/) ships as the game everyone
knows (the table in karims-taste.html has every rule) with four calls of its
own. (1) Two boards: the classic 8 by 8 is the default, and a quick 6 by 6 is a choice on the
start sheet and the party's short version (rules.party). (2) A level count is a
draw. The board keeps its even number of squares and no tie-break is added. (3) The two
players peek over the board's rim (~/ui/board/MascotRim) with their discs as their eyes,
as in Dots and Boxes, and not on the stage under the board that Checkers and Chess wear. (4) A line
never holds the board: the board is busy only for the wave of discs turning over (about half a second,
a second at most), and a mover's line goes up once it has landed and gives way to the next disc. Across phones
the turn clock is Checkers' (30 seconds by default, a disc put down for you, three missed turns in a row lose
the round). Built overnight 2026-10-04, awaiting Karim's review.
Why: (1) Checkers turned a small board down because a smaller Checkers is the same game cut short, under the five-minute floor, and "shorter" was already its rounds setting. Reversi differs on both counts: one game is one board, so rounds make it longer, never shorter, and a party needs a version of about four minutes; and 6 by 6 is a board people already play, with the same corners, edges and last-move swings in thirty-two moves instead of sixty. A party may only use a setting the sheet already offers, so the quick board is on the sheet. (2) Played at random the classic board ends level about one game in twenty-five and the quick one about one in seventeen; between people who are trying it is rarer. A tournament replays a draw with the other player opening and settles a second one by chance, so a draw costs a few minutes once in a long while. Every way of forcing a winner changes the game people know. (3) In Reversi the count is the whole story of the board, and it moves on every single move; the rim's mascots can wear a number as their eyes and the stage's cannot. Putting the score anywhere else would be a strip of numbers beside two mascots that say nothing. (4) The wave is the moment of the game and is short; a sentence that held the board after it would make every corner cost a second of waiting, sixty moves a game.
Rejected: an odd board (7 by 7, or 8 by 8 with a blocked square) so a count can never be level: it has no middle four to open on, and it is not Reversi. The opener loses a level count, or empty squares go to the leader: tie-breaks from club play that nobody at a party knows, and the second does not even break a tie. The quick board as the default: the full game is the one people expect, and eight minutes sits inside the six to ten the brief asked for. A Pass button for a player with no square: a tap that can only do one thing is a dead step, and the reducer already knows. The stage under the board with a score strip above it: two places to look. Adding eyes that count to the shared stage: a change to a shared piece three other games wear, for one game, tonight. Showing the other player's squares while they think: their options are not yours to read, and a board full of someone else's dots hides the discs. Flipping every disc at once: the wave is what shows which disc did it.
What would reverse it: Karim wanting the four duels to look like one family (then the shared stage learns to wear a count, and Reversi and Dots and Boxes move under their boards together); tables finding the quick board too slight (then it stays for parties only, off the sheet, once a party can name a setting the sheet does not show); draws turning up often enough in real rooms to annoy (then a tie-break, chosen with him).
Decision: two rules in shared code, both found by a review of Chess and both true of
every game. 1. On one shared phone a match's clock stops whenever nobody can be looking at it:
the phone is put away or the tab hidden, the playing screen is left, or the app is closed. It starts again
when somebody says so: Resume on "Pick up where you left off?", or Keep going on the paused card. It is the
host's pause (@g4g/engine pause.ts) stopped by the shell instead of a tap, so every
deadline a game wrote simply waits and no game is edited (modes/local/away.ts). A match that
comes back with its clock still running (a reload or a kill gives no warning) is stopped at the moment it
was last saved, so time nobody saw is never taken off a clock. Online is untouched: the server's clock is
the truth there. 2. The table's mode decision is refused when it names the mode
already in play (tableAction), which is all a game with one mode can ever name.
Why: (1) two friends and one phone are in the same room and nobody is hiding time from a server, so a clock that keeps running in a pocket only punishes whoever's turn it was. A saved match with eight seconds left lost on time behind the resume question, before its board was drawn. (2) a switch is a change; "switch to what we are playing" dealt a fresh board, and clocks, over a Chess game forty moves in.
Rejected: (1) starting the clock again by itself when the app comes back (the phone is still being picked up; a tap is the honest start); a heartbeat written every few seconds to know exactly when the app was last alive (a write loop for a case, a crash mid-think, where giving back the thinking time since the last move is harmless); pausing only on the resume screen (the clock had already run while the app was away); a flag on each game (every clocked game needs it, and none should know). (2) hiding the chips in each one-mode game (the action is still on the wire); a check in Chess alone (Dots and Boxes and every later one-mode game would need its own).
Would reverse it: (1) a one-phone game where time away must count (none: a speed game where leaving the app is the cheat would say so itself). (2) a table wanting "start this round again" as a host's control; that is its own decision with its own name, never a mode switch.
Decision: five calls made while fixing the tournament's review list.
1. A board is told what a tournament settled for it through a context, never through its game.
ui/MatchFrame carries three facts (the mode is fixed, a line for the stage, when the wait between
two games runs out) to ModeStrip, MascotStage, MascotRim and
RoundDialog. Why: every two-player game mounts those shared pieces, so one provider in the
tournament's board wrapper reaches all of them and no game is edited. Rejected: a prop on every Board (six
games edited for one screen's needs, and a seventh forgets), and a separate row under the board for the replay
line (the opener's mascot then stood twice, which is what the review caught).
2. The coin lands a fixed time after a phone HEARS the result. Every phone hears it in the
same push, so the two players' coin and the watchers' "The coin picked me!" meet at
COIN_LANDS_MS after that (coinTimes, heldForCoin). Rejected: a
server-stored landing time (a field written for an animation, on the one document every phone re-reads), and
timing each screen from its own mount (the players' screen opens a beat later, after their board has shown
its ending, which is how watchers came to read the result four seconds early).
3. A leaver's name comes from the matches they played. A phone that never saw them reads
the seats of the newest finished match they sat in (matchesToRemember): one small answer that
never changes again, and none at all while everyone is still in the room. Rejected: a list of the draw's
people on the programme's row (re-sent to every phone each time a match starts or ends), and leaving
"Someone" on a podium. What it cannot name: somebody who left before any match of theirs was played.
4. "Just one match" is a quiet link on the tournament's start sheet. The stand's yellow button keeps offering a tournament (the approved mock), and the old way, two play and the rest watch, is one tap further. Rejected: a second button on the stand (two primary actions), and a setting.
5. The lobby's dev buttons that started a tournament or a party are deleted. They existed "until the designed posters land on the wall", and the posters have landed; the dev-only preview pages cover what they were used for. Rejected: moving them to the dev pages (a second way to start the same thing).
Would reverse it: (1) a game that needs to know it is in a tournament for its own rules (none does). (2) phones in one room hearing a result seconds apart. (3) the programme's row gaining the draw's people for another reason. (4) Karim wanting one match to be as loud as a tournament. Built overnight 2026-10-04, awaiting Karim's review.
Decision: Chess (apps/app/src/games/chess/) is the whole game, with the
rules written here and proved by counting (the published perft numbers for five positions, in its
rules.test.ts). Four calls shape it. The clock is a chess clock, built on the
one deadline the platform gives a game. deadlineAt is the moment the player on the
move runs out; the state also keeps what each player had left when their clock last stopped. A move stops
the mover's clock (what was left when it arrived, plus a few seconds back) and sets the deadline to now
plus the other player's time. No engine change, no second timer, and the host's Pause works because the
rules are handed the match's own time. Running out loses, unless the other player has only a king, or a
king and one bishop or knight: then it is a draw. A move sent after the deadline is not played, even when it
beats the platform's timer to the rules: whatever a player sends once their flag is down is answered by the
clock, the same rule the tournament's platform clock applies (convex/lib/matchClock.ts). The
clock is asked on every kind of screen, opening on ten minutes each across phones and off on one phone; a
tournament match never has it (the platform's clock is the one clock there); a party plays the sheet as it
opens (ten minutes each in a room, no clock on one phone), because a party's short version is one list
for every kind of screen and cannot say "a short clock, but only across phones". Which side a piece is on is its body: ivory for whoever moved first, ink for the
other. Whose it is, is its foot: every piece stands on its player's seat colour. On the Midnight
board an ink piece wears a pale rim, and the board's two navies are lifted a touch so it reads as a dark
shape on both. A king in check stands on a red starburst, a shape and a red nothing else on the board has.
The draws are the rules' own and nobody claims one: stalemate, too few pieces, the same
position three times, fifty moves each. There is no draw offer; resigning is open to
either player at any time and is asked about first. On one phone the board never turns between moves.
The sliding piece Checkers proved (where it rests is layout, the animation carries the distance) is one
shared file, ~/ui/board/pieceMotion, which Chess and Checkers both draw their pieces on. Built
overnight 2026-10-04, awaiting Karim's review.
Why: a tournament decides a second draw by chance, and chess is long and drawish, so a host needs a way to give a game a length: that is the clock, and a per-move clock (Checkers' thirty seconds) is the wrong shape for a game where one move deserves three minutes and the next three seconds. Seat colours are the app's own law (a player's colour follows them everywhere), but on a board where thirty-two pieces mix the two SIDES must be unmistakable for any two of the twelve colours in both looks, and a body in the player's colour cannot promise that: measured, yellow and green on the light squares were 1.1 to 1.4 to 1, the deep blue and the cocoa on the navy 2.1 to 3.2, and sky and cyan, coral and pink, yellow and gold were told apart only by a foot. A light body and a dark body is the one cue that does not depend on which colours sit down, and it is the one every chess player already knows; the seat colour stays on the piece as the thing it stands on, so "mine stand on yellow" is still true. All 144 pairs were looked at, at a phone's size, in both looks, before it was settled. A position is remembered as a short hash, and only since the last pawn move or capture (nothing before one can come up again), so a long game's state stays small.
Rejected: bodies in the players' seat colours with a white or a deep foot (the first build: it failed the pairs above); bodies tinted with the seat colour (the dark ones turn muddy and stop looking like one side); ink pieces with the dark outline on the Midnight board (they sink into it: hence the pale rim); a coral frame round a king in check (it is the coral player's own colour, and a wash is what the last move already leaves); a party forcing the five-minute clock on every kind of screen (on one phone the sheet opens with no clock, and a party asks nobody); a setting only a party sends, "at most this clock" (the party contract allows a short version only what the game's own sheet offers, and its test says so); a late move played on the bonus alone (it earned time after the flag had fallen); a per-move clock; a clock kept beside the state by the platform for ordinary rooms (that is the tournament's, and it gives time back up to a cap, which is not a chess clock); a draw offer (two more buttons, and a question one player can ask again and again; the fifty-move rule and repetition already end a dead game); the strict rule for a flag (a draw whenever no helpmate exists: it needs a search, and no table expects it); turning the board to face whoever is on the move on one phone (it spins a board that is lying on a table); a chess library (a game adds no dependency, and the rules here are counted against the same numbers every engine is).
What would reverse it: Karim wanting more of the seat colour on a piece than its foot (then a second trim, never the body); tables asking for a draw by agreement; the party contract learning to say "per kind of screen" (then a party in a room plays the five-minute clock).
Decision: three rules of a tournament, each made simpler after a review found the first
version could be played against. The clock. Each player's clock runs while the game says the
turn is theirs, and a third of a full clock comes back once, when the turn passes to the other player
(or the round ends). A turn of many actions (the hops of a Checkers jump, a chain of boxes, a hit that
fires again in Sea Battle) is one turn. Where both players act at once (hiding the fleets in Sea Battle),
the game names the first player still at it as holding the turn: their clock is the whole phase's
allowance and no action by either player stretches it. The picks. A fan can back a player
from the draw until 45 seconds after the match starts, or until it ends, whichever comes first, and never
in the rematch after a draw. Nothing about moves is counted. The points. When the
programme's one writer settles a match it marks every pick on that match right or wrong in the same
write, and each phone reads its own fan's picks through one small query
(programmes.myPicks). The round's countdown is 10 seconds.
Why: the clock used to give time back on every action by the player on turn, so one player reshuffling ships could hold a round of fifteen matches open for as long as they liked; a tournament's whole point is that no phone can stall the room. The pick lock used to count the match's writes as "moves", and arranging ships is writes: four taps closed a match's picks before a shot was fired, while the screen said "before move 4" about a rule that also closed at 30 seconds. One rule a person can say aloud beats two that disagree. 45 seconds, with the 10 second countdown before it, gives a fan about a minute to look across four matches; it is also the shortest clock any match can have, so picks are never open when a clock gives a win away. The points used to be worked out on each phone from the pick list of every finished match: about ninety subscriptions per watcher by the last round of a thirty-player tournament, all of them looked at again whenever any match ended.
Rejected: a separate fixed allowance for a phase where both players act, kept by the platform (it needs a way for a game to say "nobody is on turn but the match is on", and a way for the platform to tell the game its phase ran out: new contract for one game's one phase, when the game already names a holder); keeping the game's own placing limit inside a tournament (two clocks on one board is how a match gets called twice); a move count read from the game (every game would need to say what a move is); 60 seconds for picks (longer than the shortest clock); fan points sent inside the bracket's own answer (every pick a fan made would re-send that fan the whole bracket).
Would reverse it: players running out of time in the middle of long honest turns (then the add-on grows, or a game says how long its turn is); a game whose simultaneous phase is long enough that charging it to one player feels unfair (then the platform gets a real "everyone at once" phase). Built overnight 2026-10-04, awaiting Karim's review.
Decision: in an online room a party's awards are drawn from everyone who was part of
the night: whoever finished a game, plus everyone in the room as it ended. A friend who left is named and
coloured the way the last match they sat in remembers them, their award is handed over on the stage, and
they stand in the photo, exactly as on one phone. Where the awards show is (curtain, trophies, which
award, photo) is served by its own small query (programmes.showAt) and no longer rides in
the party's own answer. And the host's "Another party" may carry a new line-up
(programmes.again with party): the room goes from one night's photo straight
to the next night's "next up".
Why: the list of people was read from the live room at the end, and an award whose winner was not in it was dropped: somebody who won two games and went home at eleven vanished from the ceremony, while the one-phone party kept them. The show moves a dozen times, and each step used to re-send every phone the whole party (awards, people, every game: 12 KB a step for eight people, 36 KB for thirty) to change three small values. A new line-up through the lobby would have flashed the living room on thirty phones between two mutations; one call keeps it one step.
Rejected: a table of its own for the show's position (a new table needs a reaper and a sweep, to save a re-run of a query whose answer no longer changes); keeping someone who walked out of a game without finishing any (they hold no award, so there is nothing to hand them); going back to the lobby and starting again for a new line-up.
Would reverse it: rooms where people rotate through all night and the photo fills with people nobody remembers (then the photo takes only who is here and the stage still names everyone). Built overnight 2026-10-04, awaiting Karim's review.
Decision: a table game still remembers the mode it was last on into its next match, but
that memory now wins only when no sheet was shown (a replay). When the host answered the play-again sheet,
the sheet's mode is played. The engines say which it was by handing the game's memory on through
carryAfterSheet (packages/engine/src/rematch.ts), and the table's one reader
(readTableSettings) lets the sheet win when it sees that. The sheet itself now opens on the
answers as the match ENDED: a field whose answer can change during play says where it lives in the state
(live: 'pick' on the mode field) and the one sheet both shells share reads it
(answersAsPlayed). Built overnight 2026-10-04, awaiting Karim's review.
Why: the remembered mode was added before the play-again sheet existed, when Rematch asked nothing, and it was given the last word. Once the sheet arrived there were two answers to "which mode" and the older one won: pick the other mode, Start, and the old one played again, in every table game and in both shells. The mirror was the same fault seen from the other side: switch the mode with the chip mid-match, and the sheet opened showing the mode from before the switch while Start played the new one.
Rejected: dropping the memory and reading only the sheet (a replay with no sheet, which the hotspot host still does, would forget a mode switched with the chip); a flag saved in the match's settings (it would be replayed with them and go stale: an answered sheet two matches ago would silence a later switch; the mark rides inside the memory instead, which every engine writes afresh each time and no client can send); writing the switched mode back into the room's saved answers on every switch (a room write per chip tap, for something only the next sheet reads).
What would reverse it: the table reporting its own answers as they stand (one small method on the table's rules), which would let the memory stop carrying an answer at all.
Decision: meta.clock still says a game CAN run on a clock. A game whose
clock is a choice also says whether the match on screen has one: rules.hasClock(state). The
three shells no longer read the flag themselves; they ask hasClock(game, state) in
packages/engine/src/pause.ts. Checkers, Dots and Boxes and Sea Battle declare it (their turn
clock may be Off, and one phone is never asked); every older game says nothing more and keeps its pause
exactly as it was. Built overnight 2026-10-04, awaiting Karim's review.
Why: those three games ask their clock on the start sheet, and only where every player holds a screen. On one phone, and in a room whose host left the clock Off, the header offered a pause with nothing to pause. The answer is a fact about the match, so it is read off the match.
Rejected: naming the three games in the shells (the next game with an optional clock
would repeat the fault); making meta.clock a function of the settings and the mode (meta is
plain data the site, the server registry and the pickers all read, and the settings are not the match: a
tournament match plays with no turn clock whatever the sheet said); offering Pause whenever a deadline is
set right now (a game that holds a deadline for a moment between rounds would flash a pause button).
What would reverse it: a clock that can be switched on or off mid-match by something other than the rules' own state.
Decision: the play screens' scroll is now as wide as the screen, with the page's gutter
inside it as padding (usePlayScroll), so nothing clips at the column's edge. A board of eight
squares a side may reach into that gutter on a phone: as far as a 44px square needs, never closer than 8px
to the screen's edge, and not at all on a phone wide enough already (useBoardReach, the rule in
ui/boardReach.ts). Checkers and Sea Battle use it. Checkers also takes NEAR MISSES: a tap on a
light square, which is never played on, goes to the nearest dark square beside it where a tap does
something. Built overnight 2026-10-04, awaiting Karim's review.
Why: a square was 41px on a 375 phone and 34 on a 320, under the 44 a finger needs. The board already had the whole column; the 20px either side was the page's. Reaching in gives 44 at 375, 42 at 360 and 37 at 320. In Checkers the light squares are free room, so a piece's real target becomes its square and half of each light square round it: 53px on a 320 phone.
Rejected: a smaller gutter for every play screen (every game would move for two games' sake); the board edge to edge (no margin at all, and still only 40px on a 320 phone); hit slop (it does nothing on the web or Android); near misses in Sea Battle (every square there is live and a shot cannot be taken back, so a tap that moved to a neighbour would fire a shot nobody aimed).
What would reverse it: Karim preferring the board in line with the header on small phones (then Checkers keeps its near misses and Sea Battle needs aim-then-fire to be finger-safe at 320).
Decision: a match row carries a small list of its seats (each one's id, name and colour),
written once in the same insert that starts the match (gameStates.seats,
convex/lib/matchSeats.ts). The two match-state queries hand it to screens only once the match has
ended. The result screen reads names and colours from the room's live list as before, and takes from the
match's copy whoever is no longer in that list (~/ui/resultRoster). Built overnight 2026-10-04,
awaiting Karim's review.
Why: a room's member list is live. A guest who leaves, or is removed, after the game is over has their row deleted, while the result still ranks their seat. The winner screen looked the name up in the live list, found nobody, and drew the winner as "Player" in the first colour; the shared picture then kept that for ever. Who played a match is a fact about the match, so the match holds it.
Rejected: keeping a departed member's row until the room is back in the lobby (every reader of the member list would have to learn to skip ghosts, and a removed guest could not knock again as a new person); a separate table of match seats (a second document and a second write per match, for thirty short strings); sending the list with every state update (a move's payload would grow by a name list nobody needs during play); reading the result from the snapshot alone (a player who is still here and changed their name would be shown the old one on the screen they are looking at).
What would reverse it: names or colours that must stay frozen for a whole match (then the snapshot is read during play too), or matches that outlive their room.
Decision: seven calls made answering an independent review of the saved files. (1) Every joint winner is drawn as a winner. A small table stands up to five on the gold block; a big one stands four to twelve crowned over the grid and gilds their cells past that. (2) A name tag hangs under the feet on every picture, the podium included: it sits on the block's face, the place under it, and it shrinks with the podium. Silver and bronze are a little taller than the mock's to hold both. (3) The brand label stays on the mock's story-safe line and nothing drawn comes within a clear gap of it; a short grid lets the podium grow instead. (4) Words are measured and cut by whole characters, and a script the app's fonts do not hold is estimated by its kind rather than counted as full squares. (5) A browser makes the file before the button can be pressed, so the press that shares is a fresh tap with the file in hand, and the preview becomes that file, a real image. (6) How it leaves is chosen by what the browser can honestly do: share a file, download on a laptop, or be pressed and held. (7) On the result screen Share is a small button in the actions row, and the actions are pinned under the standings.
Why: (1) the old picture said "4 winners!" over three of them. (2) A tag across the body hid half a mascot once the podium shrank, and every other picture already hangs the name under the feet. (3) The floor under the label is what a story covers, so it is not room to use; the mock draws its own safe-margin guides there. (4) A flag is two codes and a family seven: cutting by code left half a flag, and counting every non-Latin letter as a full square cut names that fit. (5) A browser refuses a share that does not start inside a tap, and the file took up to 4.6 seconds; an early tap fell through to a download. Most of that time was the drawing library copying the whole page, so it is now told to copy the picture only (thirty players: about one second). (6) Inside Instagram's or WhatsApp's browser a download can do nothing at all, and no browser reports that one started; the mascot was saying "Saved to your downloads" on a guess. (7) A full-width Share pushed Rematch below the fold from five players on a tall phone.
Rejected: moving the brand label down into the bare floor (it is the story-safe line, by the approved mock); a second podium row for extra winners (a fourth gold block beside the bronze reads as fourth place); measuring text on a hidden canvas (a phone has no such thing, and the two platforms must lay the picture out alike); trying a download on a phone when a share is refused (nobody can say whether it happened); a labelled Share on a phone's actions row (three words do not fit beside two buttons at 320 wide).
What would reverse it: a browser API that confirms a download; a phone whose own JavaScript measures text; Karim preferring the word Share on a phone, which costs the row back.
Decision: five calls made building the tournament's screens. (1) The screen keeps its own place, the server keeps the truth. A match's result is known the instant it ends, the next round is drawn in the same step and starts six seconds later. A phone shows one moment at a time: its own board until the ending has been seen, then the result, then whatever the room is doing. How far this phone has got with each of its own matches is three marks kept on the phone; which moment that means is one pure, tested function. A result gives way by itself when the player's next match is due and waits for a tap otherwise. People watching the last match get about two seconds before the draw or the podium takes its place. (2) The bracket never promises a match that has not been made. The rules draw a knockout's pairs afresh every round. So a line runs from a match to the place its winner takes, and nowhere else; rounds still to come are open places; once a round is drawn the earlier rounds are ordered so its lines do not cross. (3) A quick game is best of three by default. The rules do not say which games can end level, so the length of one game stands in: under five minutes plays best of three, longer plays one. The host can change it on the sheet. (4) The game's own options ride on the tournament's sheet, minus the ones a tournament sets itself, which are read off the engine rather than listed. (5) Guests cannot vote for the Tournament poster. A vote is stored as a game the room could play, and a poster is not a game.
Why: (1) every gate passes a screen that jumps from a board to the next bracket; only a person watching sees the winning line cut off. Holding the place on the phone needs nothing from the server and cannot disagree with it, because it only delays what is shown. (2) A tree drawn in advance would be wrong two times in three. (3) Tic-Tac-Toe between two careful players is a draw, and a second draw goes to the coin: one game a match would make the coin the tournament. (4) The mode is fixed for the whole tournament, so the sheet is the only place to choose it. (5) Adding a field for it is a backend change for one tap.
Rejected: asking the server to wait for each phone's animations (it would let one slow phone hold the room); a fixed bracket (the rules were reviewed and merged with a fresh draw each round); drawing the mock's tree anyway; a "can this game draw" flag on every game (a contract change for five games when length already separates them); a second sheet for the game's options.
What would reverse it: a longer wait between rounds would let the result and the bracket
breathe (one number, COUNTDOWN_MS). If the rules ever fix the bracket at the start, the picture
can draw the whole tree again. If a long game turns out to draw often, it wants a flag of its own.
Decision: five calls made building the screen everyone without a match of their own is on.
(1) A screen holds open only the matches it draws. A phone shows one match and subscribes to
one; a big screen shows a page of four (two on a narrower one) and subscribes to those. A round of fifteen
matches is four pages; the other three cost nothing until someone turns to them. (2) Reactions belong
to a match. They travel on the room's one live socket under a topic made from the match's own name
(react:r1m2), so only the screens looking at that match send and hear them. Quick Draw, Charades
and Undercover keep the room-wide topic. The two players of a match hear none unless they switch them on, and
then they hold the same topic. (3) The game's own board is drawn, watch-only, through the shared play
shell. The shell takes one flag (watchOnly) and tells the shared pieces a board reaches
for: no "Watching" pill and no turn card (the watching screen says who plays and whose turn it is), the mode
chips go, and the locked round sheet stays shut. (4) Whether picks are still open is the server's
rule, asked by the screen (picksOpen): the buttons close on the move, or the minute, the
server closes them. (5) A fan's points are counted on the phone from the picks of finished
matches, by the same function the podium's Best fan is counted with. Built overnight 2026-10-04, awaiting
Karim's review.
Why: (1) every move in a subscribed match wakes the screen holding it. Fifteen matches on thirty phones is every phone woken by every move in the room, which is the load the per-match subscription was built to avoid. (2) A cheer for one board landing on another is noise, and a player concentrating on their own match did not ask for it. A topic is just a string to the relay, so scoping costs no new socket and no relay change; a room still opens one. (3) A second way to draw a game for watchers would be a second copy of every board. The flag is one rule in the shared layer, so no game is edited and no game learns what screen it is on. The round sheet has to stay shut because it is locked until the players start the next round: over a screen of several matches it would hold a watcher on one. (4) Two copies of a lock rule drift, and the one that is wrong offers a button that always fails. (5) It needed no new server function tonight.
Rejected: subscribing to every live match on a big screen (the load above); small boards drawn from a summary the server would have to compute per game (a second board per game, and a server that knows what a board looks like); one live socket per match (a watcher hopping between matches would reconnect on every hop, and the relay's cap is per room); filtering reactions by a match name inside the message (every phone would still be handed every match's emoji and each feed's rate limits would count the wrong ones); hiding the board's own two mascots in the games that draw them (Checkers, Sea Battle, Dots and Boxes), which would mean editing games or silencing their own lines: there the watching screen stays quiet about turns and the board tells them.
What would reverse it: (1) if people on a big screen ask to see every match of a long round at once, the answer is a light per-match summary from the server, not fifteen subscriptions. (5) A thirty-player set-rounds tournament has about a hundred finished matches by its last round, and each watcher asks for each one's picks. They never change again, but it is a hundred small questions per phone: the server should answer "my picks" in one. That is the first thing to change if tournaments get long.
Decision: five calls made putting the party into an online room.
(1) Where the awards show is, is one small value on the party's own row
(programmes.show), served to every phone with the rest of the party. It only ever moves one step,
from the moment the caller names, to the moment the show's own rule says comes next (moveShow in
convex/lib/partyRun.ts, over nextShow in ui/party/show/showSteps.ts): the
host's button (programmes.showNext) and the server's own clock (programmes.showTick)
both go through it. No phone sends a position. (2) The server is the show's clock. Each move
books one job for when that moment has waited long enough, the closed curtain and the trophies' table
included. (3) The show's people are kept when the party ends: everyone in the room at that
moment, by name and colour, stored with the awards. The stage draws from that list, and the server counts the
awards on stage with the same rule the stage uses (onStage). (4) The party the host is
building rides the room's one mirror (room.pendingSettings.party, written by the same
debounced write as the picked game), so guests see it without a new field or a second writer.
(5) "Start now" is a host's call the server makes (programmes.startNow, naming
the game), not a countdown a phone ends. Built overnight 2026-10-04, awaiting Karim's review.
Why: (1) a ceremony is watched together, so "which award" is shared state, and a phone that reconnects mid-show must land on the right one. Naming the moment being left makes a double tap, a tap racing the clock and a late job all harmless: the second one finds the show already gone from there. It costs about a dozen small writes a party, by one person. (2) The one-phone show moves on by itself only while the holder's phone is awake. In a room that would hang everyone on the host's phone: a dead battery mid-ceremony would leave thirty people on one award. (3) The live roster shrinks when someone goes home, and the stage leaves out an award whose winner it cannot name; drawn from the live roster, one person leaving mid-show would change which award "number three" is on every phone at once, and out of step with the server's count. (4) Two writers to one field overwrite each other; one writer cannot. (5) Every phone counts down to the server's stored moment and none of them starts a game, so a phone with a wrong clock changes nothing.
Rejected: the live relay for the show's position (it stores nothing, so a phone that reconnects would not know where the room is; it carries the cheers only); the host's phone sending the position to store (a stale or doctored phone could put the room anywhere, and two taps in flight could skip an award); every phone ticking the show on when its own timer runs out (thirty writes racing for one step); keeping the position inside the stored awards (they are written once, and the rule that nothing rewrites them is worth more than saving a field); new fields for votes on the posters and for a guest ticking several games (the wish a player holds is one game, and the host's sheet already shows those counts).
What would reverse it: a room wanting the host alone to pace the show (drop the booked job for the curtain and the table, keep it for the awards); or names kept for people who have left (a match already keeps its seats' names), which would put them back on stage and in the photo.
Decision: seven calls made building the one-phone party and the screens both shells share.
(1) The party is saved beside the match, in the one store, in one write. Starting a game of
it, ending one and dropping one each write the party and the match together, and the steps in between are
pure functions of the saved party with the clock handed in. (2) The living room shows the party
itself. A shell hands the room one more thing, a "show" (what is on the TV, the plate, two keys, a
countdown, one line from one person), and the room draws it with what it already has. The posters are things
on its wall. No second lobby screen exists for a party. (3) A party offers only games that seat
everyone in the room. There is no seat picker between games. (4) A game's short version is
one optional member of its rules (rules.party: the settings laid over its defaults, and
the minutes), filled in for six games that already had a shorter setting. (5) The countdown is a saved
moment, and it waits while the holder is busy. It stops while a name is being typed or a sheet is
open, and a countdown the phone slept through starts again instead of firing. (6) The cup is something
the mascot holds: a new held item in the mascot kit, so it rides with every hop and is the same cup
on the stage, in the photo and in the shared picture. (7) Ending a party early gives no awards.
The way out asks first, in the same words as ending a game, and goes back to the room. Built overnight
2026-10-04, awaiting Karim's review.
Why: (1) a phone can lock at any moment. Two saves that can land apart mean a party that says "playing" with no match, or a finished match with no party to give its result to; one write makes that impossible, and a saved party that still disagrees with its match is put right as it is read back. (2) The mode shells are one product: the online adapter passes the same show and the same posters, and writes no screen. (3) A party runs by itself; a question between games ("who sits out of Ludo?") stops the night and leaves someone watching a whole game. The sheet names the games that are for smaller or bigger groups, so nobody wonders where they went. (4) The games already knew how to be short; a party only has to ask. The key is in the rules because the online party is started by the server. (5) Five seconds is not long enough to type a friend's name, and a game with a clock must never start in a pocket. (6) A cup drawn beside a mascot stays behind when the mascot jumps. (7) An award says what happened over the night; half a night has not happened yet.
Rejected: a second persisted store for the party (two writes); keeping the party in the set-up screen's memory (gone on a reload); a "party lobby" screen of its own; asking who sits out before each game; a short version written into the party code as a table of game ids (the games would not own it, and a new game would be forgotten); inventing quick modes for games that have none; a countdown kept in a timer (lost on a reload) or left running under an open name field; a trophy laid over the mascot by the screen; awards for a party ended after one game; a Players-sheet remove that leaves the leaver on the party's roster (the next game would deal them a seat).
What would reverse it: Karim wanting a table of five to keep Ludo in the party (then a rotation, decided by chance, never by the host); five seconds feeling too short or too long at a real table (it is two numbers); hosts wanting the awards after an early end (then End party goes to the show when at least two games were played); a game whose short version needs a mode it does not have yet (that is a change to the game, with its own entry).
Decision: Sea Battle ships as the hidden-fleet game everyone knows (the table in karims-taste.html has every rule) with six calls of its own. (1) A sea is 8 by 8 and a fleet is five ships covering fourteen squares (4, 3, 3, 2, 2). (2) A hit fires again. (3) It plays on one shared phone as well as across phones: each player hides their fleet in private behind the shell's own hand-off, then the phone rests between them showing only the sea under fire, with nobody's unsunk ships on it. (4) The secret never reaches a phone that may not see it: a view is built field by field, carries a seat's own fleet and only the sunk ships of the other, and someone watching gets no fleet at all. (5) On a phone one sea is on screen at a time, the one being fired at. (6) The board is busy only while a shot flies and lands; the shooter's line and a sinking ship play on afterwards as an "echo", and the next shot may be aimed at once. Built overnight 2026-10-04, awaiting Karim's review.
Why: (1) Eight squares a side is the most a 375-wide phone holds at a thumb's size (about
41 each). Fourteen ship squares on sixty-four keeps a miss more likely than a hit, so a hit is an event, and
no ship is one square long, so nobody hunts a single square for five minutes. The length is an estimate,
not yet timed at a table: about thirty-five shots each at a few seconds a shot is six to ten minutes.
(2) The seas do not touch each other, so who fires when changes only the rhythm, never what is fair. Firing
again turns a hit into a run, which is where the suspense is, and it halves how often the turn changes hands.
(3) After the fleets are hidden, everything left on screen is something both players already know: where
shots landed and which ships are down. So the battle needs no hand-off at all. The rules keep the shell's
"whose screen is it" seat still (the same resting pointer Undercover uses) and read whose shot it is off the
shots themselves. The flag for a shared phone is the same-device shell's reserved hostRun
setting, which the server strips from anything a client sends, so nobody can switch a room into "anyone may
fire for the shooter". (4) A view written as a list of things to hide leaked in Undercover; a view built
from a list of things to send cannot forget a new
field. The test is not a search for a value (a square is just a number): it moves every untouched ship of one
fleet somewhere else and asserts that what the other player and a watcher are sent is byte for byte the same,
at every beat of whole matches, with a second test that plants leaks to prove the comparison can fail. (5)
Half the suspense is watching a shot come in at your own ships, and a second sea shrunk into a corner is too
small to feel that. A big screen shows both. (6) The first build held the board for each line, as Checkers
does. Played through, a run of hits felt stuck: the second tap waited up to two seconds for a sentence.
Rejected: a 10 by 10 sea with the classic five ships (squares under 34 points on a phone, and a quarter of an hour); a one-square ship; ships that may not touch (fewer layouts, and a rule to explain when a ship will not go somewhere); strictly alternating shots; a salvo of three shots a turn (a second rule set for one mode); online only (a shared phone works once the battle shows no secret); passing the phone on every miss with each player seeing their own fleet (forty hand-offs a game); dragging ships (a gesture that must be proven on a device, for something a tap already does); the mode read from the shell instead of a setting; one more turn field for "who fires" (it would be a second copy of what the last shot already says); a count of how many ship squares are near a miss, or any line that hints at distance (a leak or a lie).
What would reverse it: Karim finding the run of hits unfair at a table (then shots alternate, and only the rhythm changes); games running long (then four ships); a phone table wanting to see their own fleets during the battle (then the hand-off on every miss comes back, or it becomes a two-phone game); the lines on every miss feeling like noise (then misses stay silent and only hits speak).
Decision: in a party, a change of roster passes nothing over and ends nothing. "Up next" simply points at the first game still to come that fits the room as it stands. The games before it that do not fit are passed over, each with its reason, at the moment that game starts. The party ends when the list is done, or when a game is due to start and nothing left fits. The awards gained six every-game kinds that find a standout at a small table (won the opener, won the closer, most wins in a row, the biggest climb from one game to the next, a better second half, two rivals who kept finishing next to each other), and when the night earned fewer than three awards, the best things simply true of a player step up to the stage. A player's own award is never a prize for coming last: a statement that would have to name a last place is not made, and the wooden spoon is someone's own only when it is the one trophy they won. Every award must be true of every game of the night, the two-player ones included. Built overnight 2026-10-04, awaiting Karim's review.
Why: the first cut decided fit the instant the roster changed, so one phone dropping for a second between games skipped every game that needed three and ended the party for good, and the phone coming back changed nothing. That is the project's own rule broken: a deferred action that finds the world unready must re-arm, not vanish. Moving the decision to the start moment makes the dip cost nothing, with no extra state: what is "up next" is read from the room each time. The awards changed because a review measured them: two friends over four games got an empty ceremony four nights in ten, and one fallback in eight read out a last place as if it were a prize. The ceremony is the whole point of party mode, so it cannot be empty and it cannot be unkind.
Rejected: (1) waiting a few seconds after a roster change before skipping. That is a timer whose only job is to guess whether the person is coming back, and it still ends the party if they take longer. (2) Inventing awards to fill the stage ("best dressed"). An award only ever says something that happened. (3) Dropping the three-awards floor for nights where everything was level. The floor is met with true statements, handed to different people by lot, never with invented ones. (4) Giving nobody "most last places" at a table of two. It is a real trophy and the copy makes it kind; it just stops being someone's keepsake when they won anything else.
What would reverse it: hosts wanting the party to wait for a missing player instead of ending at a start moment (then the backend holds the countdown; the rules already allow it), or Karim finding the stepped-up awards ("played every game") too thin for the stage, in which case the floor drops to what the night earned.
Decision: the set-rounds format pairs a round with an exact method instead of a search with a budget. From the top of the table, each player takes the nearest score they have not met, provided everyone left over can still be paired fresh, and that last check is answered exactly (the most pairs that can be made among people who have not met). Two players meet twice only when leavers made it unavoidable, and then as few as can be. The free pass goes to whoever is due one and costs the round no rematch. The picker's top end is the most rounds that never needed a rematch with nobody leaving: three for up to four players, then two short of "everyone else" with an even count and one short with an odd one. Everyone-plays-everyone is offered up to eight players. Built overnight 2026-10-04, awaiting Karim's review.
Why: the first cut tried pairings one after another with a budget of four thousand steps, again for each number of rematches allowed. At thirty players asked for every round, one step took two and a half seconds. The server cuts a step off near one second and retries it identically, so that tournament would have stalled for ever. The same budget let avoidable rematches through from about twenty-five players. And the picker offered "everyone else" rounds, which cannot be kept: six players can be left as two threes who have all met across. Making a round now takes about half a thousandth of a second at thirty.
Rejected: (1) a bigger budget, or one shared budget. It moves the cliff, it does not remove it. (2) Capping the picker at half the head-count, the most a proof guarantees. Measured over thousands of played-out tournaments per head-count, a rematch never showed before the last two rounds, and the backend already stops at seven. (3) Offering everyone-plays-everyone to a full room. Thirty players is 435 matches in one document that every result re-sends to every watcher.
What would reverse it: a played tournament showing a rematch inside the offered range with nobody having left. Then the range drops to half the head-count, which is the proven bound.
Decision: seven calls made while fixing what a review of the tournament and party backend found. Built overnight 2026-10-04, awaiting Karim's review.
| Call | The rule |
|---|---|
| The host leaves | The room closes, exactly as when the host closes it: a running tournament or party stops, every match freezes, every seat goes, the door is cleared. This holds in every state, the lobby and the podium included. A guest can always leave; only the host takes a finished room back to its lobby. |
| Backing a player | Picks are open from the draw until the match is four moves in, or thirty seconds old, or over, whichever comes first. The rematch after a draw takes none. The server and the screens ask one shared function. |
| Who may read | The bracket and the fan picks answer the room's own members. Someone at the door, or holding only an id, is told nothing. The board of a game with nothing hidden stays readable by its room code, as it is for every ordinary room. |
| A party game's settings | The host's phone sends its answers for each game. The server never takes three things from it: who the host is, who runs the table (the host), and the game's short version. It writes those itself as each game starts, also when the phone sent nothing. |
| A match's result | The move that ends a match leaves the result on that match's own row, marked unread. The programme's one writer reads it, settles the fixture and moves on in a single write. Results that were played to the end are read before leavers and before a skip. |
| What a phone is sent | The bracket travels packed: each player named once, each match sent once, no finishing places on a fixture. One file packs and unpacks it. Thirty players over seven set rounds is about 15 KB at its biggest; it was 115 KB. |
| One clock | A tournament match never gets a game's own turn clock, whatever the host answered on the game's sheet. The platform's clock is the only one. A move that arrives after it ran out is not played. |
Why: everything a room does next is the host's to start (the next game, a skip, play again, back to the lobby, the door), so a room left running without one is a dead end for everyone in it. A pick made after the last move, or in a match where a phone has gone quiet, is a pick on a known winner; the shortest clock a match can have is 45 seconds, so the window is 30 and a test holds it under every two-player game's clock. Rooms are private with a knock at the door (2026-08-14), and the bracket names everyone inside. A table game started with no settings had nobody in charge, so anyone, a late joiner included, could switch its mode and wipe the board. With the result on the fixture, the bracket was sent to every phone twice per match end, and a skip or a leave landing between the last move and the advance overwrote what had been played.
Rejected: (1) handing the room to the next player when the host leaves. Only the host has an account, and paid features are metered against it. (2) A separate table for results waiting to be read. It needs its own reaper, and the match row is already being written by that same move. (3) Building a party game's whole settings on the server. A game's starting settings live on the app's side of the game today, so the server cannot read them without every game folder changing; every game's setup already falls back to its own default for an answer that is missing or wrong, so the answers are safe to take as sent. Moving the defaults into the rules is the larger fix and is still open. (4) Sixty seconds for picks: longer than the shortest clock, so it would have closed nothing. (5) Keeping one shared copy of the bracket for the room, with no check of who is asking. Membership needs the asker's session, so each member now has a copy; it is small and changes a few times a round.
What would reverse it: hosts who leave by accident and lose a room mid-tournament would bring back a hand-over, behind a confirm. Fans saying thirty seconds is too short to choose would move the window, never past the shortest clock. A game that needs its own timer inside a tournament (a real-time duel) would need the platform clock and the game's to be told apart.
Decision: the backend for tournaments and parties is three tables and one writer.
programmes holds one row per tournament or party: the shared rules' own state and nothing a move
changes. fixtures holds one small row per match: who plays, whether it is waiting, live or done,
when it starts, which match it is played on, how it came out. fanPicks holds one row per watcher
per match. Every match is an ordinary gameStates row started by the ordinary engine, and every
engine door (state, moves, pause, leave, removal, rematch) takes an optional match id, with the room's current
match as the default. So a single-match room and a tournament run the same code. Built overnight 2026-10-04,
awaiting Karim's review.
Who writes what. A move writes its own match row. A match that ends writes its own fixture
and books programmes.advance. Only that advance (and the host's own start, skip and end, through
the same code in convex/lib/programmeRun.ts and partyRun.ts) writes the programme, and only at the very end does
it write the room. The advance is not told what happened. It reads what is true: who is in the room, which
fixtures have finished, which are due. Then it asks the pure rules what follows and makes the fixtures match.
So running it twice, late, or out of order changes nothing.
Why: the three spike rules. Thirty people playing fifteen matches must not all write one document, or their moves fail at exactly the busy moment. The bracket must not read any match, or every move would wake the whole room. And a round must start because the last match ended, not because a poll noticed.
The rules settled while building it (each one is a constant or one branch, easy to change):
modeSwitch: 'players'), so every table game gets it without a line of its own.Rejected: (1) The matches inside the programme row. One document that every finishing match writes is the hot document the spike rules forbid. (2) A second engine for tournament matches. Two copies of the referee drift, and the second one would have to relearn timers, pause, leaving and hidden information. (3) A timer that polls for a finished round. It races whatever else reacts to the same moment, and it runs when nothing is happening. (4) Making each game's own turn clock mandatory. Half the games have none, and editing games for a generic layer is not allowed. (5) A picks-locked flag written by the fourth move. That is a second document on the path every move takes. (6) Locking picks by time. The approved screen says "before the 4th move". (7) Moving the rounds on from each phone. A phone that is asleep would hold the room.
What would reverse it: tournaments far bigger than a room of thirty (the state would have
to be split by round), or Karim preferring a turn limit to a clock that gives time back (one function in
packages/engine/src/playClock.ts).
Decision: every final result screen, on one phone and in a room, carries one more button,
Share. It opens "Your picture": the result as a tall poster (1080 by 1920), always in the
mint look, shown exactly as it will be saved, with one button under it. On a phone that button is Share (the
system share sheet, which is also how the picture reaches the camera roll); on a laptop it is Download. The
mascot says when it is done. Four pictures exist, chosen from what every phone already holds when a game ends
(the game's name, the seats, places, scores, a winning side): the winner on the gold block with the rest
ranked in front (up to six players), two players face to face with the score, a winning side together on a
bench, and a big table as a podium over a grid of places (seven to thirty). The picture is ordinary app
drawing: ~/ui/sharePicture builds it from the mascot kit inside one shared frame (the room's
wall and floor, the garland, the brand label), and the tournament podium, the award card and the party's
group photo are new heroes inside that same frame. Nothing is uploaded and nothing touches the backend. Built
overnight 2026-10-04, awaiting Karim's review.
Why: Karim sees this as the growth loop ("like people share their PUBG wins"), so the thing that matters is the saved file, and the file has to be recognisably ours in someone else's chat. That is why it ignores the player's dark mode, why it carries the brand label, and why it is made on the device: a picture nobody had to upload costs nothing at any number of players and cannot leak a room.
How the file is made, and why that way. The picture is drawn twice from the same
components: the preview, and a "plate" out of sight at the file's full size, which is what is captured.
On a phone the plate is laid out so that it is exactly 1080 by 1920 device pixels and
react-native-view-shot captures it; the file goes to the share sheet under a name someone can
find later (expo-file-system renames the capture). In a browser html2canvas paints
the plate into a canvas with the page's own loaded fonts, and the file is made as the sheet opens, because a
browser only lets a share start inside the tap that asked for it. All three native modules were already in
the app for the room's QR card, so this needed no native rebuild.
Rejected: (1) One SVG document, the way the QR card is made. The mascot is a puppet of moving layers, not one drawing, so the picture would have needed a second, hand-kept copy of the mascot, and two copies drift. (2) Letting the browser photograph its own page (an SVG "foreign object"). It needs every font embedded by hand and is the least dependable route on iPhone's browser, which is the phone most guests will hold. (3) Capturing the preview. A phone's preview is about 300 points wide, so the file would be an enlargement. (4) One full-size poster scaled down on a phone. A phone keeps a bitmap for every drawing at its laid-out size, so a full-size poster would hold hundreds of megabytes that nobody sees. The phone lays the picture out at the size it is shown instead; the web, where scaling is free, lays it out full size and lets the browser shrink it. (5) The mock's Undercover picture (the civilians in party hats, the one they caught, the two words). Undercover's end-of-game result is a score table over several rounds and names no side, so that picture cannot be drawn from what a phone is sent, and a game is not edited for a shared layer. The team picture is drawn for any game that does name a winning side. (6) The kicker's "best of 3": the generic result carries the score, not how long the series was.
What would reverse it: wanting the picture to exist where no phone is drawing it (a link
preview for a shared result, a picture in a notification). That needs a server to draw it, and the plan
(plan.ts) is already plain data a server could draw from.
Decision: Dots and Boxes ships as the schoolbook game (the table in karims-taste.html has every rule) with five calls of its own. (1) It seats two, although the paper game also plays with three or four. (2) Its boards hold an odd number of boxes (9 and 25) and its rounds come in odd counts (1, 3, 5, 7; one by default, on the big board), so a finished match can never be level. (3) The turn clock (across phones: 30 seconds by default, 1 minute, or off) never passes a turn: when it runs out, a line is drawn for that player, picked by the seeded dice, and it counts like any other. (4) The two players peek over the paper with their boxes as their eyes, on the rim Ludo and Chain Reaction use, not on the stage under the board the other duels stand on. (5) The "thank you" line for boxes handed over is said only when the mover had a better line. (6) A tap never waits for the line before it: what the screen offers comes from the moment on screen, what a tap does is checked against the rules as they stand. Built overnight 2026-10-04, awaiting Karim's review.
Why: (1) The table knows two kinds of seats: a duel, where one player is on the clock, the opener swaps each round and a walk-out stops the match; and an open table, where everyone plays at once and nobody is on the clock. Turns going round three or four people is neither, and a game must never write seat code of its own. A tournament also pairs games that seat exactly two, so a two-to-four game would start an ordinary match for four friends instead of offering them a tournament. And with three or four, level scores come back. (2) A tournament settles a draw with a rematch and then by chance; a game that cannot be drawn never needs either. (3) Late in a round every line gives boxes away, so a turn that merely passed would be worth stalling for: the clock would become the best move on the board. A drawn line keeps the game going and is no reward. It is on by default, at the same thirty seconds as Checkers, because a phone put down must not hold up the other player; on the big board a dead phone is then played out by the clock in fifteen minutes at the very worst, and the host can end the game long before that. (4) The rim already carries a number as a mascot's eyes and takes a game's own lines and feelings, so the score needed no strip and no shared piece had to change. (5) Every line of an endgame hands something over. A mascot that said "thanks for the present" each time would be calling the game itself a mistake. (6) A chain is the best moment of the game and it is tapped out fast. A board that ignored a tap because the last line was still drawing would drop every second tap of a quick thumb. The lines still play one after another, in order, so nothing on screen is ever ahead of anything else.
Rejected: two to four players on an open table with the turn checked by the game: the table would still never swap who opens, would hide the turn as "nobody on the clock", and the game would be carrying seat rules the engine owns. Boards of 4×4 or 6×6: an even number of boxes can end level. A 7×7 board: 112 lines is a quarter of an hour, and on a phone the gap between two dots drops under 44 points. Even round counts: one round each is a level series. Passing the turn, or losing the round, when the clock runs out: the first is exploitable, the second ends a friendly game over a phone put down. The clock off by default: the other new duel has it on, and two sibling games that disagree about a default is a thing to explain. Losing the round after three timeouts in a row, as Checkers does: Checkers can go on for ever and this board cannot, so the paper itself is the limit. Size as mode chips above the board: the board is settled before the first line and nobody switches it mid-round, so it is a row on the start sheet. Adding an option to the under-board stage so it could show a count: another builder was changing that shared file the same night, and the rim needed nothing.
What would reverse it: the table learning a third kind of seats (turns that go round three or more) would bring the four-player game back, with a leaver's boxes staying theirs on the paper and a tie shared. Karim wanting chips on it: "fewest boxes wins" and a board with its outer fence already drawn are the real variants. A review that finds the rim odd beside the other duels moves the two mascots under the board.
Decision: Checkers ships as the game everyone knows (the table in karims-taste.html has every rule) with four calls of its own. (1) Its one extra mode is Giveaway: the same board upside down, lose every piece or get stuck and you win. (2) A round can never run on: twenty turns in a row with nothing taken and only kings moving ends it on a count, a piece worth 1 and a king 2, level being a draw. (3) Across phones the host picks a turn clock (30 seconds by default, 1 minute, or off). Run out and a legal move is made for you, picked by the seeded dice; run out three turns in a row and the round is the other player's. (4) The board tells its own turn story on the shared stage: it mounts the same two-mascot stage the other duels get from the play shell, and hands the speaker one line for a beat ("King me!") through a small new option on that stage. Built overnight 2026-10-04, awaiting Karim's review.
Why: (1) Giveaway is a real, named variant every checkers player has heard of, it flips the feel of the same rules completely, it is one sentence to learn, and it is the same idea as Avoid in Tic-Tac-Toe and Connect Four. (2) A tournament settles a second draw by chance, so a game that drifts into a draw is a bad tournament game, and two casual players with kings cannot finish each other off. The count turns "I am ahead" into a win without endgame skill. Men moving stops the count, so it only runs once both sides are shuffling kings. Simulated over hundreds of games it adds at most twenty turns and leaves about one game in twenty level. (3) A put-down phone must not stall a room. A move made for you keeps the game going for a slow player; but a phone that has died would otherwise be played by the clock for the rest of the game, thirty seconds a turn, while a tournament waits. Three in a row is ninety seconds. (4) The shell's stage reads the settled rules, which have already passed the turn on while the piece is still sliding, and it can only say whose turn it is. A board that plays its moves out has to draw everything from one moment, the turn line included.
Rejected: a quick 6×6 board as the extra mode: it is the same game only shorter, about three minutes, under the five-minute floor, and "shorter" is already the rounds setting. Both extra modes: the brief was one, and two chips nobody asked for is a start sheet to read. "Take the most pieces" as a jump rule: nobody at a party knows it, and it makes the legal move something to work out. Letting a crowned man keep jumping: tables differ, and stopping is the rule most people were taught. Counting heads, not kings worth two: two kings against three men is a winning board, and the count would call it lost. Forty quiet turns (the club rule): four more minutes of kings going round. A clock that only ever plays a move for you: see the dead phone above. A clock that forfeits at once: too harsh for someone who is merely thinking. The peeking rim over the board for the lines: it already takes a line, but the three duels should look like one family, and the stage under the board is what the other two wear. Leaving the stage to the shell and saying the line somewhere else: two things speaking at once.
What would reverse it: Karim finding the count confusing at the table (then a plainer ending, or the count shown all game); tables asking for the quick board; the line after every capture feeling slow (then lines only for multi-jumps and crowns, and no hold); the party's short versions needing a real short Checkers, which would be the quick board after all.
Decision: a room can run a programme instead of one match: a tournament (several
two-player matches at once, over rounds) or a party (several games in a row, ending in awards). Everything that
decides anything about either is one pure module in the shared rulebook
(packages/engine/src/programme/): the pairings, the free pass, the rematch and the coin after a
draw, what a leaver costs, the table, the podium, the best fan, the party's running order and its awards. It is
plain data in and plain data out. The backend calls it for online rooms and the one-phone store calls the party
half. A game does not know it is in a programme: a tournament starts a match with two settings (its length and
who opens) and reads one result back; a party reads finishing places, plus optional numbers a game shares
(facts on a result, declared by rules.awardFacts). Built overnight 2026-10-04, awaiting
Karim's review.
Why: a tournament has to give the same answer on the server and on every phone, and a party has to survive one phone locking. Both are exactly what the games' own rules already do, by being pure and seeded. Putting the deciding in one such module means there is one copy of "who plays whom next" and it can be tested without a backend or a screen: every format from 3 to 30 players, draws, and people leaving at any moment are checked in under a second.
Rejected: a "meta-game" whose rules wrap other games' rules. It would have made a tournament one giant game state holding eight boards, rewritten on every move of every match, with every spectator re-reading all of it; a game's contract (one board, one turn, one result) does not describe several matches at once, and each child game's clock, hidden hands and removal rules would have had to be re-plumbed through the wrapper. A playlist on the room document (the room remembers a list of games and an index). It is enough for "play these four in order" and nothing else: no place for results across games, so no awards; no pairings or standings; and the rules for skipping and for a changed head-count would have been written once in the backend and again on the one-phone side. Fan picks inside the tournament state: a room of fans tapping at the start of a round would all be writing the record every match result is written to.
The smaller calls made while building it (each one a line to overturn):
What would reverse it: a programme that needs to look inside a game while it is being played (a tournament of a game with no single winner, a party that scores points across games) would want a contract with the games, not just their results. And if tournaments turn out to be run with thirty people in everyone-plays-everyone (435 matches in one record), the matches move out of the state into rows of their own.
Decision: two things. (1) Android's back button or swipe closes the thing that is open before
it leaves the screen: any sheet in the app (the shared Modal owns this once, through
useBackClose), and in the room the name box and a tapped name tag. A locked sheet stays, and so does
the screen under it. (2) The people added on the one-phone set-up screen are remembered the moment they are added,
not only when a game starts. Leaving the room and coming back finds the same people on the sofa.
Why: back with a sheet open left the room and the friends just typed in were gone. The table lived only in the screen's memory, so the in-app back arrow lost them the same way, on every platform.
Rejected: a "leave and lose your players?" confirm: it guards a loss that does not need to exist, and adds a question to the most common tap on the screen. Moving where the table is kept removes the loss instead. Back handling per sheet: nineteen sheets, and the next one would forget.
What would reverse it: a wish for the sofa to start empty each visit; then the table is cleared on purpose, with its own control, not by leaving.
Decision: the device remembers one of three choices. Match device is the default and stores nothing: the app follows the phone or the browser, and keeps following it while open (a phone that turns dark at sunset takes the app with it). Light and Dark are the player's own pick and win over the device. The header button shows the look on screen (a sun or a moon) and one tap opens the three choices right under it, the one in force ticked; it is the same small menu the ••• beside it opens, on phones and big screens alike, and the button wears that family's square face. Profile's Appearance card shows the same three rows. A look picked before this existed keeps working with nothing to migrate: what was stored was already "light" or "dark".
Why: since the phone app started following the phone's setting, a player who had once tapped the switch had no way back to "follow my phone" (Karim, 2026-10-03: "we should support light, dark, and system mode"). This reverses the 2026-09-06 call below, which dropped "match my phone" because the default already did that; true only until the first tap.
Rejected: a button that cycles through the three (a sun and a moon can be told apart, "match device" cannot be read from an icon, so nobody would know where they had landed); a bottom sheet on phones (the three-option sheet is what "felt broken" on 2026-09-06, a sheet is a lot of screen for three rows, and the button next to it already opens a menu under itself, which is also what iOS does for a short pick list); keeping the on/off switch on Profile (a switch has two states); storing the word "system" (nothing stored already means it, and the web page's early script needs no change); a second copy of the choice in our own store (the styling engine already knows whether it is following the device, so the tick reads it from there).
What would reverse it: Karim finding the menu one tap too many for a thing he flips often; then the button flips light and dark again and Match device lives only on Profile.
Decision: the icon is the coral mascot's head on the app's mint ground; on a dark iPhone it
stands on Midnight, and the tinted and Android themed icons are a one-colour stencil of it. The launch screen is
the whole mascot standing still on the same ground. The app's first second starts from that exact picture: the
antennas spring, it blinks, the name rises in, and the app appears. The two-G neon mark is retired everywhere
(icon, launch screen, favicon, touch icon, manifest icons, brand/logo). How the pictures are made:
releasing, "The icon and the launch screen".
Why: Karim picked it from five live mocks. It is the same friend people meet inside the app, on the home's own colour, so the icon, the launch and the product are one thing and nothing changes colour when the app appears.
Rejected, one line each: Pop-up on yellow, the loudest tile, but yellow is our button colour and would have to turn mint right after launch. The crew, three friends, the only one that says "group", but two of them are coloured bumps at small sizes. Midnight, calm on a dark phone, but it shows the night look to people opening a mint app. The wink, the clearest at 29 pixels, but no antennas, so less clearly our mascot.
Also decided: the iPhone icon is three plain pictures (light, dark, tinted) rather than an Icon Composer bundle. The pictures are cut by a script from one drawing and show exactly the approved art; the bundle is a format only Apple's tool writes, and it layers its own glass and shadow over the art. And the app follows the phone's light or dark look until the player picks one (it was fixed to dark at the phone's level, which would have shown the dark launch picture to everyone).
What would reverse it: the icon reading weakly on pale wallpapers in real use (mint is a soft colour), or Apple requiring the bundle format.
Decision: on the web, a colour (or width, or opacity) that differs between light and dark is written into the page once, as a CSS value holding both answers. Two switches in the document head say which look is on: the system's, or the one the player picked. Native keeps plain values.
Why: the page is built ahead of time in one look, and the script that could repaint it arrives late. If the page's own markup is the same in both looks, there is nothing to repaint and nothing for React to disagree with (learnings, 2026-10-03).
Rejected: hiding the page until the script has run (no flash, but dark-mode visitors wait on
a blank screen, and it is a wait bolted on rather than a cause removed); building every page twice (the host
serves plain files and cannot choose by look); moving every drawn colour into the stylesheet's tokens (the home,
the mascots and the game cards keep their palettes in code, where their maths lives); the browser's own
light-dark() (colours only, newer browsers only, and react-native-web drops it).
What would reverse it: a browser that does not resolve these values inside a drawing's own colour attributes. Checked in Chromium; Safari and Firefox document the same behaviour for colours.
Decision: the host picks Number ("Eighty-two") or Digits first ("Eight, two. Eighty-two", the default). The Nickname style ("Two little ducks, twenty-two"), its ninety lines of copy and its tests are deleted. A room or a remembered choice that still says "nickname" plays Digits first: the rules read anything they do not know as the default, and the start sheet shows the default picked.
Why: Karim, after playing it: "It's kind of distracting to me." A nickname is a second thing to decode in the seconds a player has to find the number.
Rejected: keeping it as a third option nobody is pushed towards. An option the owner finds distracting is a worse game for the tables that pick it, and ninety lines to keep true in every language.
What would reverse it: players asking for the traditional calls by name. The sourced list is in git history (2026-10-01).
Decision: the own-ticket nudges are removed ("Come on, 47!", "47! Daub it!", "So close!", and the
sixteen "Claim it!" variations, with their selectors and copy). The mascot climbs over a ticket only while that
ticket can claim a prize right now, and says one of five lines, "Claim {prize}!", with the prize's own name. With
several ready it names the first in the order prizes go. It cannot be tapped. The answer comes from the rules:
claimRefusal says why a claim would be turned down (not daubed, not open yet, taken) and
claimablePrize is the first prize with no refusal; the reducer turns claims down with the same function,
and the tests read it on playerView's output.
Why: Karim played it: a mascot waiting on one number "is distracting", and the mascot is worth having only where it tells a player they are eligible to claim. One fixed line per prize, because the news is the prize's name; a bank of jokes hid it. Not tappable, because "they have to explicitly scroll up or click on the claim buttons. Mascot should not claim."
What we rejected: keeping the waiting nudge but quieter (still a mascot with nothing to act on); a tap on the mascot making the claim (built first, removed the same day on his word); scrolling to the prize tiles on a tap (still a shortcut).
What would reverse it: players missing claims because the tiles are off screen above a long
list of tickets. game.ts changed (the two selectors; callsNeeded and
prizeNumbers removed), so it needs bunx convex deploy before the next site deploy. Tambola
is online-only, so the LAN guest bundle's game list is not affected.
Decision: the lobby's TV screen is only the picked game's picture, with nothing laid over it.
The TV stands on a low wooden stand drawn as furniture (ui/room/Stand.tsx, layout.stand),
and the stand's front holds the next step: for whoever picks, one yellow "Pick a game", then the arrows either
side of a cream name plate (name, "2 to 4 players, 20 min", the "?") over "All games" and the yellow "Start"; for
a guest, who is picking over "Vote for a game", then the name plate over "Other games" and "I want this" and who
everyone is waiting for. A phone has the same stand, so nothing is docked at its foot (layout.dock
is gone). The stand's feet are on the floor line, and the room keeps the wall for each viewer's tallest stand, so
the sofa and the people do not move when a game is picked; a shorter stand lets the TV sit lower. Everyone sits
forward of the stand, so no head and no held-up sign reaches it. The button says "Start": the plate right above
it names the game. On a phone the door stands taller, so the host's answers to a knock sit above the stand's keys.
Why: the caption strip laid over the foot of the screen (2026-10-02) covered the picture, and Karim rejected it. Four ways to name the game without covering it were mocked, and he picked B, the stand: one piece of furniture holds everything, nothing floats on the wall, and on a phone Start sits beside the TV.
Rejected: A, a taller TV with a small display in its chin (the tallest stack, and light words on a dark band read quietly). C, a framed "Now playing" poster beside the TV (the eye hops sideways, and a phone has no wall beside the TV). D, the remote's middle key as the label (the name is smallest there and reads as a button, not a title). Also rejected: keeping a phone picker's Start docked at the foot (the stand is at the top of the room and always on screen before any scrolling, so a second place for Start is not needed).
What would reverse it: hosts on a phone missing Start because it is no longer under the thumb, or long game names reading too small on the plate between the arrows on narrow phones (a name steps down in size, then takes two lines; on a 320-wide phone the "?" steps aside).
Decision: the game's name, who it is for, how long it takes and the "?" how-to are a caption
along the foot of the TV's screen (white with dark ink in both appearances; the whole strip opens the how-to). With
nothing on, a guest's "Vote for a game" is a button in the test card's lower band. The controls sit in one box
right under the TV (layout.plate): the remote over the yellow next step for whoever picks, and a
guest's "Other games" and "I want this" over who the room is waiting for. On a big screen that is true for
everyone and nothing is pinned in a corner. On a phone a guest's controls are under the TV too, and only the
picker keeps the remote and Start docked across the foot (layout.dock), because the yellow button
must stay under the thumb. The layout lowers the floor line until no head and no held-up sign reaches the
controls, and the big room is drawn at the largest scale that still fits the window. The lobby's lines stay
plain ("Karim is picking a game", "Waiting for Karim to press play").
Why: Karim, testing as a guest on a laptop: "That's the main area people are looking at. The bottom area is not where they will look… everything we can show on the TV itself, not in the bottom-right corner." A control far from where the eye rests is a control people do not find.
Rejected: one row of remote plus Start under the TV (a long game name does not fit beside the remote on a small room); a guest's vote buttons at a phone's foot (there is room under the TV without shrinking the TV or the sofa, at 2 to 30 people); letting signs overlap the yellow button (a sign is tappable for the host, so it would steal a tap meant for Start); a bank of jokey lines for who is picking and who is waited on (Karim read the samples: "I didn't like any of these… the current one is better").
What would reverse it: the room reading as too small on a laptop. The controls do not scale, so with more than nine people (standing room behind the sofa) the big room is drawn roughly a tenth smaller than it was with the corner dock.
Decision: a free place draws nothing. No dashed outline, no empty cushion, no hover, no yellow on one shared phone, and no "Sit anywhere you like" line (the line, its key and the device's memory of having been told are deleted). The places are still real tap targets with a screen-reader name, and a free sofa arm also takes a tap on the arm's own body, under everybody on the sofa. On a big screen the door mat is not drawn while the host's two answers stand on it. The Players sheet draws each knocker as a mascot head in a candy card, with the door's own two buttons, in the colour the room would hand them; a game in play works the colour out the same way the lobby does.
Why: Karim, on the build of the entry below: the dotted circles "look odd… let them figure out by clicking on random places. It's like an Easter egg… people can discover and share". A delight that is found gets told to friends; one that is labelled is furniture. The arm's body became a target because with no outline to aim at, people tap the thing they see, and the arm's own target is a thumb-high strip mostly above it. It sits under the people so it can never take a tap meant for somebody.
What we rejected: fainter outlines, or outlines only on hover (still a label). Lighting the places up once a friend is tapped on one phone (the same announcement, later). Keeping the one-time hint without the outlines (it is the announcement). Growing every seat's target in the layout (they are shared with the seated people, and the layout's no-overlap rule is tested for 2 to 30).
What would reverse it: nobody ever finding it. Then the first step back is a mascot that moves by itself once in a while, not a marker.
Decision: in the lobby people sit where they like. The layout (roomLayout) hands
back every place the room offers, not only the taken ones, each under a name made of its kind and number
(sofa:2, arm:0, bag:1, back:3, floor:5). A second
pure function (ui/room/seating) decides who sits where from the people in join order, each one's
picked name, and the names this screen offers. A picked place that is offered is honoured. Two people on one place:
the earlier joiner keeps it. Everyone else sits where their turn in the room puts them, so one person moving never
shifts anybody else. A pick the screen does not offer is ignored until it does. Tapping a free place hops the
mascot over. Online each person moves only themself: players.sit writes the name to the player's own
row and the roster (players.list) carries it. On one shared phone the holder taps a friend, then a
place, and the picks live in the phone's memory. The seat is kept when a game starts and goes with the row.
Why: Karim asked for it: "I would like the mascots to sit wherever they like, kind of an interactive area… if I want to sit next to some player." A name instead of a position because a phone and a big screen draw different rooms from the same data (five cushions and seven), and because room scenes people can buy are planned (a playground, a cruise ship): a scene with other furniture offers its own names and old picks simply fall back. The server checks only that the caller is seated, that the room is in the lobby, and that the value is a short name. It does not know the furniture, so a new scene needs no backend change. The player's own row for the same reasons as the popcorn vote: no shared document every mover writes, no new table to reap, no new subscription. The server refuses a place someone else already picked, so a pick never lies in wait and jumps in when its owner gets up. The seat outlives the game because people come back to their seat. The sofa offers one cushion more than the people on it (never fewer than three), so there is always a place beside somebody and two friends are not shrunk to fit five. Standing room opens only when the sofa and arms are full, because on a phone it costs the TV its height.
What we rejected: free walking (the arcade mock Karim turned down, and thirty people cannot stand anywhere on a phone). Swapping seats with someone (it moves a person who did not ask). Storing x and y (a position means nothing on another screen size or in another scene). "First free place" for everyone without a pick (one move made the whole sofa shuffle). A seats list on the room document (every move would write the one document the host's picks write). A query of its own for seats (a third thing re-run on every row change, where the roster is already subscribed). A Move button in the name bubble on one phone (three taps where two do). A tutorial: the device is told "Sit anywhere you like" once.
What would reverse it: Karim finding the outlines noisy (then they show only on a tap of your
own mascot), or wanting free places in a full room (then a move becomes a swap, which needs the other person's
yes). The backend needs bunx convex deploy before the next site deploy: the lobby calls
players.sit and reads seat off the roster.
Decision: every game now has a card scene, so the poster that stood in for a missing one is
deleted from the app: the bundled images, the list that loaded them, their test, and the poster branches in the card,
the how-to sheet and the game page. (1) A registered game with no scene fails
gamecards.test.ts; there is nothing behind a card any more. (2) The website still needs
one picture file per game, for the card a chat app shows when a link is pasted. For a shipped game that is
a still of its scene at rest, 1200 by 630, in public/posters/<id>.png. A game still on the roadmap
has no scene, so it keeps the poster it had (<id>.jpg), on its page and in its link card. One
function says which (gamePicture in catalog.ts) and the page, the search data and
catalog.test.ts all ask it. (3) The stills are taken by
bun run card:stills, which photographs the dev-only /gamecards?still=<id> in a headless
browser. The size is one constant (site/linkCard.ts) that the page head, the still and the test all
read.
Why: a fallback nothing can reach is dead code, and this one could only ever show the wrong picture. The old link pictures were the neon posters, which no longer look like the product. They also came in four different sizes, none of them the 1200 by 630 every page already declared, so there was no "old size" to keep. The two file types are deliberate: the name says which kind of picture it is, so a game that ships with only its old poster fails the test instead of quietly previewing as neon.
What we rejected: keeping the bundled posters as a safety net (the test is the safety net). Saving the stills as JPEGs under the old names: flat colour smears, and nothing could tell a still from a stale poster. A still at the scene's own shape (320:200): chat apps crop a link card to the 1200 by 630 shape, so the scene sits in the middle and its own ground carries to the edges. Drawing the pictures during the site build: it puts a headless browser in the release path for twelve files that change when a scene is redrawn. Stills for the coming games: they have no scenes.
What would reverse it: coming games getting scenes of their own (then every picture is a still and the two-kinds rule goes), or scenes changing often enough that a forgotten retake becomes a real risk (then the stills move into the build).
Decision: the room lobby and the same-device set-up screen are one scene, drawn by
~/ui/room. The code hangs as a garland of letter tiles (bunting on one phone), the people sit on a sofa
as mascots in their seat colours, the door is the knock, the TV shows the picked game with a remote under it, and
Start is the yellow button at the foot. Friends say what they want to play: "I'm in!" throws popcorn at the TV for
the game on screen, and a pick in the Guide holds up a sign the host can tap. Two thin adapters feed the room
(modes/online/Lobby from the Convex room, modes/local/SetupScreen from the phone's memory).
The decisions stay in useLobbySetup, setupRules and useSeatPicks, and both
shells shape the set-up sheet and the Start label with one function (modes/startSheet). Where everyone
sits is a pure function (roomLayout), tested for 2 to 30 people on eight widths. A wish is a field on
the player's own row (players.wants), set by players.want and cleared by
engine.start.
Why: Karim: the old lobby "was designed before mascots". He was shown three working mocks and picked the living room. A scene gives every lobby job a place a guest already understands (a door you knock on, a TV that is on something, a remote somebody holds), so the screen needs almost no words. One scene for both shells because the two screens ask the same questions, and two copies had already drifted once (learnings, 2026-08-17). The layout is pure so "nobody overlaps, every seat can be tapped, Start never covers a seat" is checked for every head-count without drawing anything. Votes are room state, not a passing event, so every phone shows the same wishes and a refresh keeps them. They sit on the player's own row because thirty guests voting would otherwise all write one room document and collide with the host's mirrored picks. Here each vote writes only the voter's row, the room's own members are the only readers (the server checks the reader's seat, as it does for the door: somebody waiting to be let in holds the room's id and is told nothing), and the row is already bounded by the seat cap, removed when the player leaves, and reaped with the room. The popcorn flying is drawn on each phone when a wish changes. It needs no relay.
What we rejected: an arcade you walk around in, and a party bus (the other two mocks). Hats and accessories for your mascot: "that won't be scalable when you have more players… that is for later." A table of its own for wishes (a new reaper and three clean-up paths for something that cannot outlive its player). Wishes on the room document (every vote would wake everyone standing at the door and contend with the host's picks). A countdown before the game (Start stays instant: pick, Start, two taps). A game already on the TV when the room opens: nothing is picked to begin with, as before, and the TV shows a test card until somebody picks. "Not now" as the door's second answer, as the mock had it: the answer sticks to that device, so the door says Keep out, the Players sheet's own words.
What would reverse it: Karim finding the scene slower than a list for a host who already knows
what to play, or a thirty-person room unreadable on a small phone (the room scrolls there, and the Players sheet is
the plain list). The backend needs bunx convex deploy before a site deploy that ships this lobby: it
reads players.wants and rooms.someoneKnocking, which an older backend does not have.
Decision: the neon poster images are being replaced by small scenes drawn in code, one per game,
with mascots playing that game (apps/app/src/ui/gamecards). (1) A scene is built from
a small kit: still drawings (Art) inside boxes that can move (Layer), a line that draws
itself (Ink), words, a speech bubble, and Mini, which is the real mascot's own parts drawn
small. (2) Motion is data: one entry per @keyframes of the approved mock, with the
mock's own percentages. The kit hands it to Reanimated's CSS animations, which are real CSS keyframes in a browser
and run on the UI thread on a phone. A card that is resting carries no animation at all.
(3) Every place and every move is a share of the box it sits in, so nothing is measured to draw a
scene. (4) Exactly one card in a list plays: the one pointed at, else a picker's chosen game, else
(where you look by scrolling) the one nearest the middle of the screen. One small store per list holds that answer
(spotlightStore.ts). A lone card on a sheet or a page plays while it is on screen.
(5) Tic-Tac-Toe, Connect Four and Quick Draw are drawn. Every other game keeps its poster inside the
same component until its scene lands, so no card is ever blank.
Why: Karim asked for it ("instead of static images… animated gif style, with our own drawings") and approved the mocks. Drawn in code, a card uses the same character as the rest of the app, follows light and dark, is sharp at any size, needs no network in any mode and weighs almost nothing. The mocks ARE CSS keyframes, so a port is a transcription, and its fidelity can be checked by pausing the mock and the app at the same millisecond and looking at both. CSS keyframes need no worklet (the crash that only shows on a device cannot happen here) and no timer on the JavaScript thread, and they cost something only while a card plays. Drawing in shares of a box means the still frame is already right in a public page's build-time HTML and on the first frame of a phone, at any width.
What we rejected: static images, which is what we had. GIF or video files: they cannot follow light and dark, they blur when scaled, they cannot rest on a chosen frame, they are megabytes to bundle for the modes that must work with no network, and their mascot would drift from the one drawn in code. Lottie or Rive: a design tool and a runtime we do not have, and there is no designer. The mascot kit's own engine (a frame clock and a keyframe evaluator running as worklets): it works, but every scene would need hand-written worklet styles, where the mock's keyframes map one to one onto CSS. A canvas that redraws every frame. Scaling a fixed-size scene with one transform: it needs a measured width (a blank or wrong first frame in static HTML) and blurs on a phone when scaled up. Playing every visible card at once: a noisy wall, and the cost of all of them.
What would reverse it: two things are unproven on a real phone at the time of writing, because
this was built and checked in a browser. Reanimated marks CSS animation of SVG properties as experimental, and the
self-drawing line uses it. If it misbehaves on a device, only Ink changes (a moving clip, or animated
props inside that one file), no scene does. And if a long list of resting cards scrolls badly on a slow Android
phone, a resting card can be flattened to one picture. Still to do: the other eight scenes. When
every offered game has one, the bundled posters, their map (ui/posterArt.ts), its test and the poster
branches go. The website's copies in public/posters/ stay: they are the link-preview images, and the
picture of a game that has not shipped. A game that has not shipped has two files there: its upright poster, which
its page shows, and a link card cut from it (the whole poster on the night ground, at the size every page promises).
Declaring the poster's own upright size was rejected: chat apps crop to a wide card and cut the subject off. So was
re-cutting the poster itself, which would have changed how the game's page looks.
Decision: four calls from Karim's review of the overnight Tambola. (1) "The hall
leans in" is removed: an open prize shows no heads and no "3 waiting", and playerView no longer builds
waiting, so no phone is sent who is close to a prize. A rules test pins the view to its public fields and
proves it does not change with another player's ticket. (2) A host setting, callStyle,
decides how a number is said and shown: the number, digits first (the default), or the traditional nickname. It rides
in state so every phone agrees; sound stays each phone's own switch. (3) The nicknames are only
published standard calls (British bingo and Indian tambola lists, named at the top of sayings.ts); 30, 49
and 88 have none and are called by digits. (4) The caller starts in the bottom right corner on every
screen and the host's mascot stands beside the ball instead of peeking over it; DraggableCaller lost its
crown slot and its top-centre start.
Why: (1) Karim: someone may have missed a number, and showing they are one away "will spoil the game for others". Spotting your own number is the game; the room must learn of it at the claim. Hiding it on screen while still sending it would leave it readable by anyone who looks at the traffic, so it is not sent. (2) Families call differently; digits first is the classic caller's way and says each number twice over a noisy room, and the nicknames are the playful option for those who know them. (3) Invented rhymes "sound very weird" beside real ones; a missing nickname is honest, a made-up one is not. (4) A mascot behind a ball with a moving ring collides with it, and the top centre of a desktop is the first place the eye and the claims are.
What we rejected: keeping the waiting heads but anonymous (a count still says someone is close); the calling style as a per-phone choice (two phones in one room would show different words for one call); friendly replacements for unkind calls ("Two snowmen" for 88: invented); the caller above the ball in the corner (taller, and it covers more of a ticket).
What would reverse it: Karim wanting the room's tension back in a form that names nobody and no
prize. The own-ticket nudges ("Come on, 47!") are unchanged and still under his review. game.ts changed
(waiting gone, callStyle added), so it needs bunx convex deploy before the next
site deploy. Tambola is online-only, so the LAN guest bundle is not affected.
Decision (the win): turning the tally over settles nothing, even when that vote decides the game. The outcome and the wins are written when the room taps past the reveal. A seat that leaves during a deciding reveal changes nothing on screen, and the tap past it pays whoever is still seated.
Why: the Players sheet showed the winners' totals during the drumroll (learnings, 2026-10-01). A result that is not in the state cannot be shown early by any screen.
Rejected: hiding the outcome and the added wins in each phone's view until the reveal ends. It keeps two versions of the truth in step by hand, and the leave-mid-reveal path would need the same hiding.
What it does not do: the voted-out seat's side still reaches every phone at the tap, because the board needs it to land after the drumroll. Holding that back needs the server to run the drumroll's clock.
Decision (the table): the round table is used wherever everybody, their names, their word bubbles and the vote mat fit without touching. Where they do not (seven or eight on a small phone, a very short window), the same players sit at the long table. The stage is never shorter than a set least height for its table size, and the page scrolls on a screen too short for it. At the long table a tap anywhere in a seat's row (mascot and card) picks that seat. Round the table the name under a mascot takes the tap too.
Why: the alternative on a small phone was people drawn on top of each other, or a page that scrolls with the chat at its foot. Twenty mascots on a phone are drawn smaller than a thumb, so the row has to be the target.
Rejected: a taller page so seven and eight keep the round table (it needs about 440 points of table, which puts the chat off the screen); shrinking round-table seats below forty points.
What would reverse it: Karim preferring a scroll over a different table for seven and eight. Still open: twenty of the widest letter (not a real word) only fits a card on the wider phones; on a 320 wide phone it wraps where it must.
Decision: while one question's reveal is up, the board draws the seats, picks and points it first saw for it. In host mode, a player leaving can close the answering window (everyone left has answered) but the answer still waits for the host. On one phone, "who got it" lists only the players being scored.
Why: a winner who left mid-reveal was swapped for the runner-up mid-sentence, and on one phone the friend who had just scored turned into "nobody got it". Host mode exists so the host shows the answer.
Rejected: keeping a removed player's pick and points in the rules state (the leaderboard and the final rankings must drop them at once).
What would reverse it: nothing expected.
Decision (Trivia Clash): a big screen whose window is under about 830 tall takes a tighter shape: a smaller host ring, a tighter bubble, answer buttons a step down. The host's bar is one row on a big screen (the count beside the button) and the board leaves exactly the bar's height clear. On a phone the host-mode host gets the small read-out answers, and their bench drops its count because the bar already says it.
Why: on a 1280 by 720 laptop the host's bar covered the bench while answers were open and part of the last answer row at the reveal. The page was simply taller than the window; padding under it only made it scroll.
Rejected: scaling the whole board to the height (fonts step, never scale); moving the bar off the bottom (it is where the thumb is on a phone, and one place on every screen).
Decision (Undercover): the sealed votes have a spot nothing else uses: the round table's mat,
and on the long table a dashed lane one card wide, with the pile spread so every card stays inside it. A word
bubble is one line, as wide as a twelve-letter word at most (three must fit across a phone), centred over its head
or held against the stage's edge with its tail reaching across (SpeechBubble's tailAt).
The round table runs on behind the near seat so that seat's bubble lies on the wood instead of across the rim, and
the mat sits in the wood left free. The layout test asks all of it of the same geometry the board draws.
Why: with ten players the pile lay over a word card, and round the table a bubble sat across the rim and near the neighbours' heads. The old overlap check assumed a bubble about a six-letter word wide.
Rejected: shrinking the seats until wide bubbles cleared (six players lost a fifth of their size for a rare long word); stopping the table short of the near seat's bubble (the table lost fifty points of height and the mat no longer fitted a card); a gap in the long table's rows for a mat (twenty on a phone fell under the size they must keep).
What would reverse it: words longer than about twelve letters turning out to be common. They are cut with an ellipsis in a bubble today; the answer then is a second line and more room over each head. Still open: on the long table a side card holds about eight letters before it cuts, and seven players on the narrowest phone sit small because the two far seats' name tags meet their neighbours' bubbles.
Decision: the reveal after a question is the question, the four answers with who picked each, and the question's winner with their line. The standings list that slid into place under the winner is deleted. On a big screen the winner stands under the question, beside the host, instead of under the answers; on a phone a photo finish keeps its line beside the winner instead of over their head.
Why: Karim, testing on a laptop: the standings came below the winner and were "not visible even on the laptop", for a moment that lasts a few seconds, and "you can still see the standings from the Players button at the top header". A block nobody can see before the screen moves on is not there; the scores already have a home one tap away.
Rejected: keeping the standings and squeezing everything until they fit (four more rows cannot fit a 720-tall window with a two-row answer grid); keeping the winner under the answers on a big screen (the screen is wide and short, and the room is beside the host).
What would reverse it: Karim missing the climb between questions. Then it belongs in its own beat between questions, not stacked under the reveal. Still open: the host of a host-led room on a 720-tall laptop has the Next bar over the bottom of the page, which covers part of the last answer row at the reveal until they scroll (taller windows are fine).
Decision: three calls. (1) The room is the screen. From the first word to the
count, Undercover shows the players seated round a table: the speaker perks up with the clock for eyes, a word
stays over the head that said it, a vote is a tap on a friend (then a button that names them, with "Not yet"), a
face-down card slid to the middle, and the count is those cards turning over and flying to who they name. The list
of names, the separate vote list and the tally list are deleted. Online the chat is a tray at the foot of the
table (the last two lines and the box, swiped up to half the screen); a big screen puts it in a panel beside the
table. (2) Nine to twenty sit in a meeting room: one long table, players down both sides and at
the ends, each with a card in front of them for their name and word. (3) Which word of a pair the civilians
hold is a coin flip at every deal (flipPair in game.ts), for our list and a
host's own.
Why: (1) Karim, on the overnight build that put mascot heads on the old lists: "we are literally translating the existing design with the mascots." The game is people round a table reading each other; a list with heads is still a list, and a vote on its own screen takes the room away while you cast it. (2) Karim: "the meeting room looks like an extension of the normal table. That way it stays consistent." A growing party never changes game. (3) The word list ships in the app and is written one way round (first word to the civilians), so a player could look their own word up and learn their side without the state telling them anything. Flipped, the list says which two words are in play and nothing about who holds which. The pair keeps its id, so the room's no-repeat memory is untouched; this closes the first of the two items left open in learnings, 2026-10-01 (the small random seed is still open).
What we rejected: the list with heads (above); an auditorium for big groups (rows of desks facing a stage: a different room from the table, so a ninth player would change the game's whole picture); a vote that lands on the first tap (a slip on twenty small seats would be final); "Sara voted" lines in the chat (her tick and her card say it, and they pushed real lines out of a two-line tray); dimming the count with a dark sheet (it stops at the page's gutters and reads as a box, so the room fades into the page's own ground instead); splitting the built-in list into two files or serving it from the backend to hide its order (the flip makes the order worthless at no cost).
What would reverse it: Karim finding twenty seats too small on a phone (the next step is two
seats at each end, or a second table), or hosts wanting to choose which word the room mostly holds (a host's list
could opt out of the flip, at the price of the leak for that list). game.ts changed (the flip, and a
votersFor selector), so it needs bunx convex deploy before the next site deploy. Undercover
is not on the LAN guest bundle's path while LAN is paused; rebuild the bundle when it resumes.
Decision: Karim compared three concepts for the question screen and picked "the quizmaster" with "the big card"'s answer buttons. A host in a bow tie with a mic stands in a ring that is the clock and says the question in one giant bubble; the four answers are white candy buttons with a coloured letter tile; long answers wrap and the button grows, never an "…". The host is the friend running the room when there is one (host mode, and the phone's holder on one phone) and the coral brand mascot when the clocks run it. Everything a host says now comes from that one place: the warm-up line fills the bubble, and "Time's up!", "Hello? Anyone?" and one phone's "Nobody? Wow." sit beside the ring. The gap between questions is counted by the same ring, with the host's Pause beside it.
Why: the question was a 17px line above four cells; a quiz's hero is the question, and a host asking it makes the screen a show. Four full-colour answer tiles under the host were "kind of overpowering" (Karim), so colour moved into a small tile. Answers of four or five words are real (the bank's longest is "Charlie and the Chocolate Factory"), and a cut answer is unanswerable, so the text wraps. One ring for both clocks means a clock is always in the same place and the reveal keeps its room for the winner and the standings.
What we rejected: "the big card" and "the countdown show" as whole screens; four painted answer tiles; keeping the separate between-questions number above the question (two clocks, and it pushed the winner's stage off a phone's first screen); the host's mascot twice on one screen (in the ring and again in a row under the answers), which is why the old quizmaster rows went.
What this reverses: the entry below said the quiz-show host is never the brand's mascot and auto play has no quizmaster. Karim's pick puts the coral mascot on this board when no friend is hosting. It is the one board it stands on mid-game, and the root card's "never on a board mid-game" line still describes every other game.
What would reverse it: the ring's chip proving too small a countdown between questions from across
a table, or the coral host reading as a second character competing with the question's winner at the reveal.
game.ts is untouched: nothing to deploy.
Decision: the emoji row shows all the time wherever the screen has room for it, and hides behind a mascot only where it would cramp the screen (Karim, reviewing Charades; the rule and his words are in karims-taste). Charades' acting screen now keeps the row at the foot of every watcher's screen, phone included; the actor's mascot takes no touches, and the "Tap me to react!" line, its two-turn counter and their tests are deleted. Quick Draw's phone keeps the mascot door: the paper takes the whole width there.
Why: the overnight build copied Quick Draw's door because it was an approved pattern, onto a screen that is a clock and empty space. A hidden control needs a reason on the screen it is hidden on.
What we rejected: a shared helper that measures free height and answers "is there room?". Two screens use the door, and both answers are fixed by the screen's design (a square paper as wide as the phone; a ring sized to leave the foot free), not by a number that changes at run time. A measured switch would also flip the row in and out between two phones in one room. So it is one decision per screen, written in a comment beside the code, with the rule in the taste doc.
What would reverse it: a third or fourth screen needing the same choice with a layout whose
free space really varies; then the helper earns its place. game.ts is untouched: nothing to deploy.
Decision: rules.announce / announceDelayMs (a game's rules handing
the play shell one line to toast to the whole room) and the shell's hook that showed it are deleted. The copy-line
shape (CopyLine) stays: the winner screen's badges use it.
Why: nothing uses it. Tambola was the last game that did, and its claim is now the claimer's mascot beside the caller; every other game says its news through a player's mascot on the board. A contract method and a hook with no consumer is a moving part with nothing to move (CLAUDE.md, "check it is wired").
What we rejected: keeping it "for later" (Karim rejects dead code kept for a future user).
What would reverse it: a game that genuinely needs one line every screen reads and no mascot can say. Restore it from git history (the commit that removed it names it), shells and docs section included.
Decision: Word Search's players are mascots (built overnight, awaiting Karim's review). Three calls came with it. (1) The pill row goes: every player peeks over the letter panel, their finds this round drawn as their eyes, in seat order; nobody has a number before their first find. (2) A find is shouted by its finder climbing up and saying the word ("Pizza!"), because that is what a table playing a word hunt does; in Parallel an opponent's word is hidden from you, so theirs is only a hop. (3) In Race, when someone claims the word your finger is partway along, the drag drops and your own mascot says "Too slow!" instead of letting you finish a claim the server can only refuse.
Why: (1) the count is the one number the room chases, and on the players themselves it needs no label, the way Ludo's tokens home became its eyes; the rim is a fixed height, so the board never moves as a table of eight wraps a second row of pills. (2) In Race the shout carries news (Bea has TIGER, stop hunting it) that the struck chip alone said silently. (3) The claim would be refused with a toast a moment later; not sending it removes the refusal instead of decorating it.
What we rejected: heads inside the pills (a second scoreboard shape beside the eyes); the finder's head on the found capsule or the struck chip (it would cover letters, and the capsule's colour already names the finder; the chip goes quiet by Karim's call); replacing the shell's toast when a word you had already let go of is taken back (the Board cannot see the refusal, so that case is a sad face only, and the toast keeps the words); emoji reactions (everyone is reading the grid).
What would reverse it: Karim finding the shouts noisy at eight players in Race (keep the hop, drop
the bubble), or wanting names back over the grid. No game.ts change: nothing to deploy to Convex, and
the LAN guest bundle is not affected.
Decision: Tambola's players are mascots (built overnight, awaiting Karim's review). Four calls came
with it. (1) The caller is the host's mascot in a headset, peeking over the floating number
ball and saying each number's traditional nickname beside it ("Two little ducks"); with sound on the voice reads the
same patter ("Two little ducks, twenty-two"). (2) Every open prize tile wears the heads of the players
one call away from it, wide-eyed, with how many ("3 waiting"): playerView builds it from every ticket on
read (waitingFor), never stored. (3) A claim stops being a toast: for the five-second
hold the claimer stands beside the ball in a party hat (a crown for Full House) with their prize and one of twenty
brags, pinned with the caller so it reaches every scroll position. Tambola was the last game with a
rules.announce, so no game uses it now. (4) Your own mascot peeks over a ticket of yours
only when it has something to feel: waiting on one number ("Come on, 47!"), that number called ("47! Daub it!"), a
claim ready, or someone else taking the prize it was one call from ("So close!").
Why: (1) the app does the calling, but the brand's coral mascot never stands on a board mid-game, and the host already runs the calling (pause, speed), which is who holds the mic at a housie night; Trivia's quizmaster set the same rule. The patter is what makes a housie caller a caller. (2) The tension of a real hall is public: people lean in and say "I'm waiting!". It says only that someone is close, never a number, and it counts calls, not daubs, so it cannot tell someone who has it from someone who needs one more. (3) A pinned moment beside the ball carries the claimer's face and voice; the toast said the same thing in system words at the other end of the screen. (4) "Waiting on one" is the most tambola feeling there is, and it is private, so only your phone shows it.
What we rejected: the coral mascot as the caller (breaks the brand rule; a friend runs the room); a strip of thirty heads for the hall (noise with no job; the heads went where they carry news, on the prizes); showing who is waiting on Early Five on your ticket (any of eleven numbers finishes it, so "one to go" names nothing); keeping the toast beside the moment (two copies of one event).
What would reverse it: Karim finding the waiting heads spoil the race, or preferring the brand as
a caller for a hall-style mode. game.ts changed (playerView now adds
waiting; announce removed), so it needs bunx convex deploy before the next site
deploy. Tambola is online-only, so the LAN guest bundle is not affected.
Decision: Trivia Clash's players are mascots (built overnight, awaiting Karim's review). Four calls came with it. (1) At a room's reveal the stage belongs to the question's winner, the fastest right answer, with their time and a line that depends on how many got it; when nobody did, the fastest answer steps up anyway, confidently wrong. (2) The quiz-show host is a player, never the brand's coral mascot: in host mode the host wears a bow tie and holds a mic on every phone, on one phone the holder does, and auto play has no quizmaster at all. (3) At the reveal every option wears the heads of who picked it. (4) Emoji reactions are left out of the reveal.
Why: (1) a quiz's moment is "who got it, and how fast"; putting that person on stage beats a
bigger number, and scaling the line by how hard the question turned out ("Too easy." vs "Just me? Wow.") is the show.
(2) The coral mascot never stands on a board mid-game (CLAUDE.md), and in host mode a friend is already doing the
hosting, so a robot presenting over them would be two hosts; in auto play the clock runs it and the players are the
whole show. (3) Every pick is public at the reveal (playerView opens them) and "who fell for the trap"
is the table's favourite part of a quiz, which was invisible. (4) The reveal is the busiest screen (answer, heads,
winner, standings, countdown) and lasts five seconds by default; floats would cover exactly the heads people are
reading.
What we rejected: the coral mascot as a permanent quizmaster in every mode (breaks the brand
rule and duplicates the host); a crowd of whole buddies while answering (thirty would be a wall, heads with tick eyes
are three calm rows); the old "waiting on…" chips (PendingPlayers, deleted: Trivia was its only
consumer); a streak badge (the rules keep no per-question history, and adding it would mean a Convex deploy).
What would reverse it: Karim wanting a presenter in auto play too, or the pickers' heads making
people feel singled out. game.ts is untouched: nothing to deploy, and the LAN guest bundle is not
affected.
Decision: Undercover's players are mascots (built overnight, awaiting Karim's review). Three calls came with it. (1) The tally page shows, beside each name, the heads of the players who voted for them. The rules already opened every vote at the tally (only the discussion seals them); the page had shown only counts. (2) At the reveal the accused wears the mascot's shades through the drumroll, whatever their side, and the shades fall off as the side lands. (3) A single player on the spot (the speaker, the accused, the unmasked) is always drawn as the whole buddy, even past twelve players, where lists switch to the face with an initial.
Why: (1) in a deduction game the pointing is evidence: two undercovers voting together, or a player who always follows the crowd, is what the next round argues about, and the reveal is the one moment the room reads it. (2) The shades are a disguise every accused wears, so they carry no information before the reveal and make the verdict a physical unmasking rather than a word appearing. (3) The face look's initial takes the place of every feeling and of the eyes, and these screens are nothing but a feeling (or, for the speaker, eyes that are the clock); the player's name is printed on the same screen, so nobody is confused when colours repeat.
What we rejected: keeping the tally to counts (loses the best clue the game produces); showing the voters on one shared phone (the room cast one vote together, and the holder's head against it would be a lie); a role-coloured hat or face before the reveal (it would leak the side); lines at game over (a winning side has no single voice).
What would reverse it: Karim finding the voter heads turn the tally into a grudge match, or the
shades reading as "cool" rather than "disguised". game.ts is untouched: nothing to deploy, and the LAN
guest bundle is not affected by rules.
Decision: Charades' players are mascots (built overnight, awaiting Karim's review). Three calls
came with it. (1) The actor wears the mascot's top hat for their whole turn, and in Reverse
every actor does: in a game about performing, the hat is what "on stage" looks like, so it says whose turn it is
before any words. (2) The reveal's funny line is in the actor's voice in Classic and the
guesser's voice in Reverse, where the whole room just performed for one person and that person talks back.
(3) The mascot kit gains a bow move (a slow dip with happy closed eyes) for the actor at
the reveal, and PlayerMascot gains a resting hat and a flip, so a guesser can
face the actor.
Why: every other game names the player on the clock with a bubble; charades is the one game where the player on the clock is standing up in front of everyone, so a costume reads better than a label. In Reverse the actors' lines would be a crowd of voices (seven people saying one line), while the guesser is the one everyone is looking at. A bow is the true ending of a performance; a cheer belongs to the one who guessed.
What we rejected: the lead actor speaking for the whole crowd in Reverse; a party hat (it is the winner's hat on the winner screen, so it would say "winning"); the actor's mascot miming along while the clock runs (a decorative loop, and Karim turned down a mascot acting things out on 2026-09-30); a row of every guesser's head over the ring (it competes with the clock, the one hero of the acting beat).
What would reverse it: Karim finding the top hat too small to read at the phone's sizes, or the
Reverse guesser's voice confusing ("who is talking?") in play. game.ts is untouched: nothing to deploy.
Decision: Chain Reaction's players are mascots peeking over the board's rim (built overnight,
awaiting Karim's review). Two calls came with it. (1) The knock-out toast ("Ben is out") is
removed from the rules (announce and announceDelayMs are gone from
game.ts): the wiped-out player's mascot cries and sinks behind the rim, asleep, and the mover says
goodbye by name in their bubble ("Nighty night, Ben."), which is a live region for screen readers. (2)
A move whose mover has a line (a chain of three waves or more, or a knock-out) keeps the board for one more second
on the settled board, so the line can be read before the next player climbs up. A move that ends the game holds
nothing extra: the winner's moment is the shells' own hold (now 1.6 s, long enough for the cheer).
Why: the toast said the same thing as the rim a second later, in a box, which is the "stage speaks, no banners" rule broken. And a line said inside the cascade alone would be up for a third of a second on a three-wave chain; the only honest places to say it are during a long chain (the size lines escalate as each size is reached) and on the settled board after it.
What we rejected: keeping the toast beside the rim (two voices for one event); a funny line said by the NEXT player (it muddles whose turn it is, and on one phone the voice); saying the size line from the first wave (it would tell the room how big the chain is before the board shows it); a hold after every chain (dead time on the ordinary one-wave pop).
What would reverse it: Karim finding the one-second beat slow in play, in which case the line
moves inside the cascade only and short chains lose their line. game.ts changed, but only in what
the client reads (Convex never calls announce), so the next bunx convex deploy carries it
with no change in behaviour; the game does not play on LAN.
Decision: the eight reaction emojis in Quick Draw, Charades and Undercover are Google's Noto
Emoji 3D pictures (128 px PNGs, about 140 KB for all eight), shipped inside the app and the web export, and
credited in apps/app/assets/reactions/CREDITS.md. They replace the set drawn in the mascot's style the
entry below introduced. The wire still carries the same ids. On a phone, Quick Draw's floating emojis now rise from
just above the drawer's mascot in the bottom-left corner and fan out up the paper; the shared overlay takes that
corner as an option, so Charades and Undercover keep their start points.
Why: Karim found our drawn set "looked cheap". A shipped set is drawn by a team whose job is emojis, and bundling it (rather than using the phone's emoji font) keeps the same look on every phone and the web. Noto's licence is Apache 2.0, which allows commercial use with the notice kept beside the files. 128 px is enough: the largest emoji on screen is 32 points, 96 pixels on a 3× phone.
What we rejected: keeping our own drawn set; the phone's emoji font (a different look on every phone, and the old Android ones clash); Fluent 3D, Fluent Color and Flat, Noto 2D, Twemoji and OpenMoji, which Karim compared side by side.
What would reverse it: a licence change, or reactions growing past a handful of emojis, where a font or a sprite sheet beats one file each.
Decision: this reverses the mood entry below, the same day. Quick Draw uses the shared
emoji reactions again, as before it: the eight emojis float up the canvas on every screen, a big screen shows
the row under the canvas, and a phone watcher opens the row by tapping the drawer's mascot in the canvas's
bottom-left corner (the round corner button is gone from Quick Draw). The row opens above the mascot with the
bar's own fold-away and spam-pause dimming, from the kit (useReactionMenu + ReactionMenuRow),
so the two triggers can never drift. The "Tap me to react!" hint stays, phones only. The pause's charge ring
is not drawn around the mascot: a pink ring around a character on the drawing paper reads as something drawn,
and the open row already shows the pause with its dimmed emojis and recharge bar. The kit's eight emojis are
now drawn as small pictures in the mascot's style instead of the phone's emoji font, in all three games. The
wire still carries the same ids. The mood set, the mascot acting, the sender's head and the kit's "set of its
own" and from stamp are deleted.
Why: Karim tried the moods and chose the emojis: "The way we are showing the emojis and the emojis flying on the canvas is better." A reaction is a quick thing fired off many times; a stream of emojis carries that, one acted-out mood at a time does not. The mascot was a good door, so it stays the door. Drawing the emojis makes every phone show the same eight and makes them look like the app, not like the phone.
What we rejected: keeping the round corner button beside a tappable mascot (two ways in, one of them sitting on the drawing); a ring around the mascot for the spam pause; keeping the phone's emoji font.
What would reverse it: watchers not finding the mascot after the hint, or the drawn emojis reading worse than the font on a small phone.
Decision: in Quick Draw the drawer's mascot stands in the canvas's bottom-left corner and
is the only way to react. A watcher (online, with the live channel) taps it, picks one of six moods (Love,
Haha, Wow, Cool, Hmm, Yay) from a bubble beside it, and the mascot acts the mood out on every screen with the
sender's small head popping up over it. The moods ride the shared reactions kit as a set of their own
(useReactions(live, active, MOODS)): the same channel, send limit and spam pause, only different
ids, and who sent it is the relay's from. A flurry plays its newest mood, never a backlog. A
watcher's first two watching turns carry a "Tap me to react!" line, counted from the game state, not stored.
On a big screen the slot under the canvas that held the watcher's emoji row stays empty, so the canvas is the
same size on every turn. Charades and Undercover keep the eight emojis.
Why: Karim found the always-visible emoji row distracting, and the drawing is what everyone is looking at: a reaction aimed at the drawer belongs on the drawer. Acting it out on the mascot makes one reaction a moment the whole room sees, instead of one more emoji in a stream.
What we rejected: the emoji row and its corner button (always there, and it floated emojis over the drawing); player heads floating up the canvas; pop-up cameos of the sender's own mascot; an always-visible row of moods; six same-colour heads as the options (they "all look similar", so each mood has its own face and its word). Giving the watcher the empty big-screen slot back as canvas was also rejected: the canvas would change size every time the turn passed to or from this player.
What would reverse it: players not finding the mascot tap after the hint, or wanting reactions on one shared phone (where the table cheers out loud today).
Decision: the mascot takes an optional surface (light or dark). The antenna
stalks are the outline ink on a light surface and the body's light tone on a dark one; without a surface they
follow the theme, as before. Quick Draw's drawer stands on the paper, which is light in both themes, so it
passes surface="light", and its speech bubble is drawn in the light appearance there
(uniwind's ScopedTheme), so the bubble reads the light tokens instead of a colour typed into the
game. The small player head (PlayerHead) takes the same prop.
Why: the dark-mode stalk rule (2026-09-28) was really a rule about the ground behind the mascot, and assumed that ground is always the app's. On the paper a yellow mascot's light-toned stalks vanished in dark mode.
What we rejected: always drawing the stalks in the outline ink on the canvas by a Quick Draw special case (the next mascot on a light card would hit the same bug); passing the bubble a hand-typed light palette (a second copy of the light tokens that drifts).
What would reverse it: the stalks getting their own outline, so they show on any ground.
Decision: the redesign branch goes into main once (it was already reviewed overnight and is live), and from then on each batch of work (two or three games, or one feature) gets its own short branch, one pull request, a review round, and a merge into main. Releases are built from main.
Why: the redesign branch reached 110+ commits and was shipped to the site and TestFlight from the branch, so main fell behind production and any review of it had become one huge read.
What we rejected: an intermediate integration branch that collects reviewed batches and merges into main at the end (Karim's first idea): it recreates the one big final review and drifts from main while it waits.
What would reverse it: needing to hold finished work back from main for a coordinated launch, where a release branch would earn its place.
Decision: every play screen puts its board just under the header at every
width. A phone no longer centres a short board (Tic-Tac-Toe, Connect Four) in the space left; the
empty room, if any, sits under the board instead of above it. The rule is the one hook every shell
reads, usePlayScrollContent.
Why: centred, Tic-Tac-Toe on a 390 phone floated about a hundred pixels below the header with nothing above it — the same void Karim ruled out on the iPad on 2026-09-15 ("sits at the top with a small gap under the header, like the phone") and in Charades on 2026-09-17 ("nothing floats with a void above it").
What we rejected: growing each short board to fill the height (a square board is capped so its turn card stays in view); keeping the centre on phones only (the one screen size where the thumb reaches up least).
What would reverse it: Karim preferring a short board balanced in the middle of a phone once he sees it at the top.
Decision: in the dark appearance a coloured button that is not ready yet rests
as the plain candy (the surface face, its label in the quiet ink) instead of fading to half, and a
yellow player's wash (the turn card, the hand-off scene) is mixed from a warm amber rather than the
raw yellow. Light keeps both as they were. The wash lives in one helper, usePlayerWash,
beside the player's ink.
Why: party yellow laid thin over navy mixes to a muddy olive: Start, Jump in and the contact form read as broken khaki, and the first player (the host, most nights) got an olive turn card. The Crew's own deep yellow tint for the dark is a warm brown, and the amber mix lands near it.
What we rejected: a lower opacity (still olive, only darker); a greyed face in light too (the pale yellow there already reads as "not yet"); changing the raw player colour (it is also every token, pip and ring).
What would reverse it: Karim preferring the faded colour on a disabled button, or a new yellow in the player palette that thins cleanly over navy.
Decision: the Crew page is the App Home. The closing "Got a room full of friends? Host a room" banner was deleted rather than redrawn. The "your room is still going" card wears at most two tags: YOUR ROOM (only when the room's name does not already say it) and LIVE (on someone else's room while a game is on); lobby and game over are its one line, and the faces show only once someone besides you is in.
Why: the banner offered exactly what the Host door two sections above it offers (host a room, friends join from their own phones), and "don't offer what the next screen already offers" (docs/karims-taste.html). The tags follow his 2026-09-10 rules ("tags say one thing the copy cannot"); the old card had drifted to a status tag on every card, repeating its own line.
What we rejected: redrawing the banner in the Crew's look (a second Host door at the foot of the page); a "Jump back in" heading over a single card (a rail heading is chrome for more than one thing).
What would reverse it: a banner that sells something the door cannot (a premium room, say), or Karim wanting a status on every card again.
Decision: the coral antenna robot is the brand's character. The home becomes "The Crew": a sticker book of friends on a couch passing one phone down the row (picked unchanged from four live concepts). The mascot is drawn in code as a puppet of separate parts, the same drawing on the web, iPhone and Android. And the app goes light only.
Why: the Party Arcade redesign was correct but had no personality, and a character people recognise is what gives an app one. Drawing it in code keeps the exact drawing Karim approved, lets it react to taps and wear accessories that can be sold later, and adds no new library to the app or weight to the website. Light only, because every game and animation so far has needed two sets of tuning, and a look that works on one ground often fails on the other: one ground halves what has to be drawn and checked.
What we rejected: a mascot per player (only when a game is built around it, an Among Us type); Rive, the tool Duolingo uses (a designer would redraw the character, export costs a paid seat, and there is no designer: Claude draws, Karim decides); Lottie files (fixed clips that cannot react); the web page shown inside the app (not native); keeping dark mode.
Known cost: party games are played at night in dim rooms, where a bright page glares. The Crew's soft mint ground, never pure white, is the answer for now. The existing games and the header's appearance switch still carry dark mode until a separate clean-up removes it.
What would reverse it: players asking for a dark screen at night, or a designer joining who owns the character's motion (then Rive becomes worth its cost).
Reversed the same day, for dark mode: Karim switched the mascot sheet to dark and it held up, except that the antenna stalks vanished (they were drawn in the outline colour). With the stalks drawn in the mascot's own lighter tone on a dark ground, dark mode stays, because the app already supports both. What changes is the ground: he dislikes the purple stage, and four other night palettes are being tried on the Crew page. Also settled: the plain look, without the white sticker edge, beats the sticker look.
The night look is Midnight: a deep navy ground (#0f1424) with navy cards, cool grey-blue body text and navy lines, chosen from Midnight, Night Mint, Charcoal and Cocoa on the live Crew page. It replaces the purple stage everywhere in dark mode, and the stage's coloured glows go with it: dark is a plain navy ground ("remove glows"). The next day the light appearance lost its faint glows too, and the Crew home its dot grid: "I don't need it anyway." Light stays lavender, as it was. Candy colours, coloured text, the yellow headline and the mascot are unchanged. Rejected: Charcoal (the close second, and the agents' pick), Night Mint (its yellow scenes turned olive), Cocoa (warm, but it dulled the coral mascot).
Mint across the app (2026-09-30): Karim reversed "light stays lavender" after living with the mint home beside lavender screens: "I think I was wrong to not have it on the whole application." Every light screen now stands on the home's mint (#c6f1dc) with white cards and the ink outline; the quiet greys that leaned lavender (the step behind sheets, a card's recess, lines, the scrim) moved into the same mint family, and muted text and the green, yellow and cyan inks went a shade darker to keep 4.5:1 on the new ground. Two green things lost their edge on mint and now keep the Crew's ink outline in light: the active standings pill (Word Search's capsule too), and the picked game card, which wears its green ring outside the ink edge. Rejected: recolouring the neon green player. Dark stays Midnight.
Decision: auditing all eleven games' errors.* blocks against what
their reducers actually throw, the bar for giving a refusal its own sentence is not "a player could
hit it" but "the shared line would be wrong if they did". A refusal the Board already
prevents, or one the shared line describes accurately, keeps the shared line and gets no copy.
Why: almost every GameError in the repo is a guard against a move
the Board renders no affordance for — Connect Four checks canDrop before it
dispatches, Ludo greys a token with no legal move, Tambola disables a claim until the pattern is
complete. Writing a sentence for each would be dozens of lines nobody can reach, all of them real
copy to translate, review and keep true. The words that were missing were the ones that
lie: "Too slow! Someone grabbed that first." for a Word Search word you found yourself,
which nobody grabbed from you; and "This game's already over." for a move that lands just after
a round settles with rounds still to play — two phones racing for the last word in round 1 of 3.
That second one lives in the engine's shared table, so Connect Four and Tic-Tac-Toe series get it
too: it now names ROUND_OVER ("That round's over.") unless the series really is
done.
Also named, though no screen can reach them today: Spy Grid's and Trivia
Clash's host-only guards refused with NOT_YOUR_TURN ("Hold up — it's not your turn
yet."), and every such control is only drawn for the host. They now name NOT_HOST
anyway, because it costs no copy (the app already has "Only the host can do that.") and a guard
should say the true thing on the day a screen does reach it.
What we rejected: a sentence per refusal, and a gate asserting every
GameError names a reason. Both mistake completeness for correctness, and the second
would have forced a line of copy for "marked contains duplicate numbers".
Spy Grid's four lines became sentences in the same pass. They were written as
fragments for a quiet hint under a disabled START ("everyone needs a team"), and they now answer a
toast as well; a lowercase fragment is not how a toast talks. Its team floor moved into
{{size}} filled from the rule, the way Trivia's seat count already was — a number
typed into a sentence is a second copy that goes on agreeing with itself after the rule moves.
What would reverse it: a mode where the Board cannot gate an affordance (a spectator view, a replay) and refusals become ordinary rather than exceptional.
Decision: every point value in Undercover is gone — the 100 a civilian took, the 200 an undercover took, Mr White's 300, the 50 that silence cost, and negative totals with them. A game is WON or it is lost. Each game gives a win to every player on the winning side, the ones the room voted out included, and to Mr White alone when he names the word. A player's total is simply how many games they have won, and it rides in the engine's usual scores map as a plain integer, so the Players sheet and the winner screen keep working with no special case — every label around it just says wins. The match is a series of one to seven games (three by default) and the most wins takes it.
And a shared first place is not a draw. Three players on four wins each end
the match as three winners: the screen crowns all of them, shows all their avatars and says "The
winners are Ana, Dev and Sara". The rule is one shared function both shells read
(ui/crown): a draw is a match NOBODY won — a duel whose board filled up, or a
table where every single seat finished level — and several players sharing the top over somebody
else all won it.
Why: "the fun is the accusation, not the arithmetic" (Karim). Nobody at a table of Undercover has ever cared whether being right was worth 100 or 200, and the numbers were doing one real job — ordering the winner screen — which a win count does better and in words everyone already uses. Docking 50 for silence was the same mistake twice: the room already sees "No word" in red and votes on it, which is the whole punishment. And the draw wording was simply wrong for this game: a side wins together, so the winner screen was calling three winners a dead heat.
Rejected: keeping points and renaming them (the same arithmetic in
new clothes); a point per game plus a bonus for Mr White's guess — it reintroduces the
ladder by the back door; a tie-break on who was found least often (it invents a second
currency to avoid admitting several people won); counting Mr White's solo win as a win for the
undercovers too — he took that game from everybody, including them; putting the
co-winner rule in Undercover's own result(), which would have left every other
open-table game still calling shared winners a draw. The shell rule, keyed on "is there anybody
they beat", keeps Tic-Tac-Toe and Connect Four reading exactly as they always have.
What would reverse it: a mode where the margin genuinely matters (a long campaign, a league table across parties), which would want a score again — and would still want this game's own rounds counted in wins.
Decision: four calls off Karim's first online game of Undercover. (1) An option the table is too small for stays pickable. Tapping it lights it, shows its own one-line tip, and prints what it still needs — "Mr White needs 6 players" — on its OWN line in the danger ink, and START is held until the table is big enough. It no longer greys itself out with the requirement parked under the other option's description, where the two read as one sentence. The rules keep their own guard for a value that reaches them anyway. (2) Silence costs 50. A player whose twenty seconds run out is recorded as saying "No word", in red, on their row and on the vote list; the room is told; scores may go negative. (3) The vote lives INSIDE the discussion. The clock and the chat run, and each living player opens the names and casts one final vote whenever they have made up their mind. Casting it touches nothing else — not the clock, not anybody's screen — and the beat ends when the last living player votes or the clock runs out, with non-voters simply not counting and no votes at all sending nobody. Who has voted is public ("Sara voted", read off the game state so a refresh keeps it); who they voted FOR is sealed until (4) the tally, a page of its own listing every living seat and the votes it took, most on top, and not one word about sides. The HOST turns that over; everyone else reads who they are waiting on. A tie has nothing to turn over, so it says "Nobody goes" right there and any seat moves the room on.
Why: (1) a pill that silently jumps back to the default tells the host nothing, and the sentence explaining it was landing under the wrong option. (2) a player who says nothing cannot be caught on anything, so going quiet was the safest thing an undercover could do — now it is the most expensive. (3) a vote that waited for a screen of its own stopped the whole room talking at once and turned the best part of the game into a queue; and the running count beside each name that the old vote screen showed was turning an argument into a bandwagon, which is why the targets are sealed. (4) the reveal used to arrive in the same breath as the result, so the room never got to read what it had just done — and somebody has to say when a moment everyone is watching begins.
Rejected: letting a player change their vote while the clock runs (it makes the last second the only one that counts); showing the running tally during the argument (the bandwagon above); a separate "X voted" message on the live relay — the rules already know, so a refresh keeps it and a room with no relay loses nothing; "anyone living may reveal" — the rules DO know who the host is (the platform hands them in at setup, the same fact the pause is gated on), so faking it client-side was never necessary; ending the round the moment a majority is mathematically certain (it would announce the result before the room had stopped arguing).
What would reverse it: tables telling us the discussion feels over long before the clock is (a "everyone has voted, move on" nudge rather than a hard end), or the host's Reveal reading as a chore in a big room, which would make it the first living player to tap instead.
Side effect worth naming: the board's presenter is gone. It existed to hold a finished tally on screen for a breath before the reveal took it away, and the tally is now a screen the room reads and the host taps past — so every change of moment is a human tap or a clock, and there is nothing left for it to hold back.
Decision: three rules a player would reasonably ask about, settled once. (1) A tie sends nobody out — most votes goes, and when two names share the lead the round simply ends with everybody still in, on a screen of its own that says so, and the next round deals a fresh order. (2) Mr White, voted out, picks the civilians' word from SIX words — the real word, the undercovers' near-miss, and four decoys drawn from other pairs in the same pack. Right and he takes the game on his own for 300; wrong and the room plays on without him. (3) How many undercovers there are is not a setting: one up to six players, two up to eleven, three beyond. Mr White is one more player on top, and that mode needs six. A fourth call falls out of the first three: the impostors are the undercovers AND Mr White, and they win the moment they merely EQUAL the civilians still in.
Why: each one removes a way for a party to stall or to be spoiled before it starts. A tie that eliminated somebody (the lowest name, a coin toss, a run-off) hands the game to chance at exactly the moment the room has failed to agree, which is the moment the room most wants to keep arguing. Six words rather than a typed guess means Mr White is reading the ROOM rather than spelling, and nobody can argue about what he meant — the same "tap beats type" rule the rest of the product follows. And a host asked "how many undercovers?" is being asked a balance question they cannot have an opinion about: two undercovers at a table of five is a game the civilians cannot win, and they would only find out afterwards. Counting Mr White with the impostors is what stops the last two players voting for each other forever — with him on neither side, a room of one civilian and one Mr White can never end, because online nobody may vote for themselves.
Rejected: a run-off vote on a tie (a second vote with nothing new said, and it can tie again); the host breaking a tie (it makes the host a player against their friends); Mr White typing his guess (spelling, near-misses, and an argument about whether "coffe" counts); three words instead of six (a one-in-three shot on a wrong guess is too kind); an undercover-count setting — offered and refused, because the number that makes the game work is a function of the table and nothing else; Mr White as his own third side for the outnumbering check, which is the stand-off above.
What would reverse it: a table telling us a tie feels like a wasted round (a sudden-death re-vote among the tied names only, which keeps the argument and removes the stall), or Mr White winning his guess far too often, which is a decoy-count question rather than a rules one.
Decision: any game that declares a clock (meta.clock: Charades,
Quick Draw, Trivia Clash, Tambola, the word hunt) can be STOPPED by its host from the play
header's ••• menu, and started again the same way. The mechanism is one pair of numbers held by
the ENGINE, not by the game: when the match was paused, and how long it has stood still in total.
Every rule already reads the time it is handed (ctx.now) and writes its deadlines in
that same time, so the engines hand it now − pausedMs — and, while paused, the moment it
stopped. Deadlines then need no adjusting at all: the match's own time is frozen, so they wait.
The three engines keep the same pair (Convex row, the one-phone store, the LAN host), the
scheduled tick is set for as much later as the room talked for, and a tick that wakes early
re-arms rather than firing. Every screen reads the same clock through one provider
(~/ui/matchClock), so rings and bars freeze in step and resume without a jump, and a
paused board is a picture: taps are refused with the usual toast. Online only the host may pause
and the server enforces it; on one phone the holder is the host; on a hotspot it is the host
phone.
Why: Karim asked for it as a rule of the platform rather than a feature of one game — "if there is some ambiguity or discussion, the host can pause the game… make it a generic rule in the common setting for the host". Time is the only thing a pause has to change, and the games already agree to read time from outside themselves, so the whole feature is one subtraction in the three places that build a clock.
Rejected: a pause phase in each game's rules — five reducers, five sets of tests, and every future game paying the same tax for a thing that has nothing to do with its rules; moving every deadline forward on resume (a write per pause, a rounding error per pause, and a race with whatever fired meanwhile) — freezing the clock needs no writes to the game's own state at all; cancelling the scheduled tick and re-creating it — Convex jobs cannot be cancelled, which is exactly why the tick re-arms itself instead; hiding the button from guests and trusting the client; deriving "has a clock" from a deadline in the state — a game that holds one for a moment between rounds is not a game anyone wants to pause, so the flag is declared.
What would reverse it: a game whose rules need to KNOW it was paused (a penalty for stopping, a pause budget). That is a rules question, and it would arrive as something the engines hand the reducer rather than as five games growing their own pause.
Decision: Rematch no longer replays the finished match's configuration in
silence. It raises the game's OWN start sheet — the same one the couch screen and the lobby
raise — with the last match's answers already in it: the way to play, the clocks, the rounds,
the seats it just played, and where the content comes from. Start on that sheet is the
rematch. One component in both shells (ui/PlayAgainSheet), so nothing is
built twice. The separate "Same topic / Pick a new topic" sheet is DELETED: the question it
asked lives inside the start sheet's own door, and the hop that carried the answer back to the
set-up screen (the rematch intent) goes with it. The content rules are unchanged — a pack that
survives its match deals again from the same list, a pack that is spent makes new items on the
topic the room kept, and the room's memory of what it has been shown rides along either way. A
game with no options and no content of its own (Ludo, Snakes & Ladders, Chain Reaction) plays
again on the tap, with no sheet.
Why: this reverses the 2026-09-12 call that a rematch asks nothing but what to play. That rule was right about one thing — nobody wants to re-answer a settled question — and wrong about when the question is settled. A table only learns what it wants by PLAYING: sixty seconds turns out to be too long, Reverse sounds better now they have seen Classic, the board was too big. Making them end the room and set up again to change one number is the expensive path for the thing they most want to do between two games.
Rejected: a small "change the options" link beside Rematch — two ways to play again, one of which hides the thing they want; bouncing the room back to the lobby for every rematch (it loses the room's memory of what it has shown, and drags every guest through a screen for the host's decision); keeping the rematch sheet for pack games only — that is the special case this removes, and the start sheet already asks its question better.
What would reverse it: a table that taps Rematch and just wants the same game again finding the sheet in the way. The answer then is a sheet that starts on a single START with the options folded under a tap, not a silent replay.
Decision: three calls on the game Karim rebuilt from a mock. (1) One prompt a turn. The actor is dealt a single prompt against a fixed 15-second clock (a constant, not a setting) and either acts it or skips it once for 50 points, after which the next one is the one. Choosing and acting are separate beats, so the swap that used to interrupt a mime is gone: once the acting starts, the prompt is the prompt. The picking clock running out commits whatever is on screen. (2) The reveal is its own screen. A finished turn no longer hands the phone straight on: the room sees who got it, what the prompt was and what it paid, and someone taps Next — only then does the turn rotate and the pass-the-phone screen appear. (3) Built-in prompts are the default and the door is gone. Charades has no topics, no packs of ours to choose between and nothing generated, so its start sheet opens on the game's own options with one more choice row — Prompts: Built-in · Write our own — and writing your own leads to the writing screen on the way to the whistle. Its rematch offers Play again and Write our own prompts, never "pick a new topic".
Why: Karim's verdict on the built screen was that Quick Draw's UI had been fitted to a different game. Two or three prompts to choose between is a decision about nothing, taken alone, while the room waits; one prompt with one escape hatch is the decision that is actually fun, and pricing the escape keeps it real. Holding the reveal until the room taps gives the turn its payoff beat in front of everyone rather than inside the next player's hand-off. And 99 tables in 100 take the built-in prompts, so a whole screen asking "ours or yours" is a screen in the way of the game — the choice is real, but it is a row, not a door.
Rejected: keeping a picking-time setting — a host cannot have an opinion about it, and the mock's 15 seconds is the rhythm; keeping the mid-turn swap as well as the skip (two escape hatches, one of which stops the acting); hold-to-peek during the acting — on one phone the prompt must not be on the table's screen at all, and online the actor's own phone can simply keep it in one small line; revealing inside the hand-off screen (shared chrome that must not know one game's beats); auto-advancing the reveal on a timer — the room reads at its own speed, and a timer either rushes it or stalls it; a "where do the prompts come from" door for every pack game — the games with real topics (the quiz, the word hunt) keep theirs, and only a game whose content is simply ours-or-yours loses it.
What would reverse it: tables burning their skip every single turn would mean the pool is wrong rather than the price; and if the reveal's Next starts feeling like a tap nobody wants, the answer is a shorter reveal, not folding it back into the hand-off.
Decision: the hotspot host's error frame carries
{ code, reason?, vars? } and no longer carries the thrown error's message.
The guest turns those three into words on its own side. Same shape as online, where a
ConvexError has carried a reason and its values for a while — one product, three
plumbings.
Why: two reasons, and the second is the one that mattered. First, a game's own
refusal copy could not reach a LAN guest at all: the reason never crossed the wire, so a browser
guest got the shared line where the host's own phone got the game's sentence. Second, the frame's
message is the note a developer writes beside a throw — "the dragged
cells match no hidden word", "pick who guessed it" — and the guest screen printed it verbatim. The
app decided a while ago that a rules file carries keys and codes, never sentences, precisely because
it runs on a machine that does not know what language anyone is reading; the LAN transport had kept
a back door open around that rule.
What we rejected: keeping message as a fallback behind the looked-up
copy. It reads as safety and is the opposite: it is dead until something goes wrong, and then it
prints English internals at whoever is holding the phone. Every code already has a line, and the
neutral fallback covers a code that somehow does not.
No version bump: both ends ship from this repo, the new fields are optional, and an older frozen guest bundle simply ignores them and reads the shared line — which is what it read before. A protocol bump would have stranded exactly those guests for no gain.
What would reverse it: a refusal whose words genuinely cannot be known ahead of time on the reading device. That has not come up; if it did, the answer is still a key plus values, not a sentence.
Decision: two rules, replacing the two named in the entry below. (1) No difficulty setting. Every prompt comes from ONE pool; the host is never asked how clever the room is feeling. (2) The free skip becomes one paid swap. The acting side may trade the prompt for a fresh one ONCE a turn and it costs them 50 points, charged the moment it lands, with no second swap and no change to the clock. A score may go below zero. In Reverse the whole acting side pays, split the same way it splits its winnings (three actors: 15 each), and the guesser pays nothing. Only a seat that is acting may buy it, and the reducer — not a greyed-out button — refuses the second one, so the result card carries both what the turn earned and what the swap cost.
Why: a party picking a tier is a party not playing, and a prompt that lands is a matter of the room rather than of a label. A free skip meant the hard prompt simply never got acted; a priced one makes "we're stuck" a real decision at the table, which is where the fun is. The cost falls on whoever made the choice, which is why a guesser cannot spend it.
Rejected: floor the score at zero — it quietly makes every swap after the first one free, and minus fifty on the board is funnier and easier to explain; charging the lead actor alone in Reverse (the whole side reads the prompt and the whole side wanted out of it); letting anyone seated swap, as they may award (spending someone else's points); a cheaper swap — at 10 or 20 it is just a skip with extra steps.
What would reverse it: tables never using the swap at all would mean the price is too steep, and it drops before it goes free. If one pool starts feeling samey across many parties, the answer is a deeper pool and better rotation, not tiers.
Decision: the acting screen is rebuilt around one column — the players in the shared row, a slim bar with the round at one end and the clock at the other, the PROMPT filling everything left, and the buttons under the thumb. The reveal is that same card turned face-up in the same place, with the points landing under it, so the screen the room was staring at shows its hand rather than being replaced. And on one phone the hand-off now opens the turn when there is no reveal to read — the first turn of a match — while every later turn still opens on the reveal and waits for one tap under it.
Why: Karim played it and found half the phone empty, with everything floating at the foot; and the very first thing a player was asked to do after saying "I'm Dev, show me" was to say it again on a screen carrying nothing. That last point partly reverses the 2026-09-01 entry below, which deleted an unconditional auto-start because it also skipped the reveal. Both things are now true at once: nothing is confirmed twice, and no reveal is ever skipped — the guard is written as the state being protected ("is there a result to read?"), not as a mode or a flag.
Rejected: auto-starting every turn (the 2026-09-01 mistake again); putting the reveal on the hand-off screen (shared chrome that must not know one game's beats); a reveal card floating in the middle of the screen — it just moved the empty band inside a card; a full-width GOT IT on a desktop, which is what stretching to the board column actually draws.
What would reverse it: a table finding the first turn's instant start jarring — the answer is then a one-beat "you're up" card, not a second tap.
Decision: four calls, taken together. (1) Anyone seated may tap Got it and Skip, not only the actor — their hands are busy miming, so the tap belongs to whoever is nearest the phone. It is not "anyone scores for anyone": the reducer only accepts a point handed to a seat that was actually guessing, so an actor can never name themselves. (2) Reverse Charades joins Classic as a second way to play, offered as mode chips on the start sheet exactly the way Tic-Tac-Toe and the word hunt offer theirs. One player guesses and every other seat acts, same prompts and same clock; the guesser rotates in seat order. The guesser takes the guesser value whole (10–20) and the actors share the actor value (50–100) equally, rounded to the same steps of 5 the melt uses and never below 5 — 100 over three actors is 35 each. (3) The host can write the prompts: the game declares a pack (the same door the word hunt uses), a written list replaces the built-in draw for the match, and the room's memory of what it has been shown still carries into a rematch. Nothing is generated by a model. (4) The acting clock running out ends the turn as "nobody got it" with the prompt revealed, and a tap that lands after that is refused in words ("the clock beat you") rather than doing nothing.
Why: (1) the person who must tap is the one person in the room who cannot reach the phone. (2) Reverse is the one published variant that needs no new content, no new clock and no new screen, and it covers the shy player by putting the whole room on stage; the split keeps a match's totals comparable to Classic's, and equal shares mean no argument about who mimed hardest. (3) A charades prompt has to be fun to watch someone attempt in silence — a judgement about a room, not about a topic — which is not something a model can be trusted with, and the word hunt already proved a model writing from a typed topic hands back the wrong thing. (4) A deferred action that finds the world unready must re-arm, never vanish (principle); a turn that simply stopped being was the last place in this game where a moment could pass with nothing said.
Rejected: only the actor (or the host) awards — today's rule, and the reason a correct guess kept going unpaid while the actor finished the mime; every actor takes the full actor value in Reverse (a Reverse match would pay three times a Classic one for the same turn, and the scoreboard stops meaning anything across modes); the guesser rotating on its own counter — the guesser is simply the seat before whoever holds the turn, so both roles rotate in plain seat order and the one phone is handed to an ACTOR rather than to the person who must not look; built-in prompt packs behind the door (the difficulty setting already picks our pool — a second door to the same content is a feature explaining itself away); giving the room a grace window when the clock dies mid-award (a clock that is sometimes not the clock is worse than one that always is).
What would reverse it: a real party finding Reverse turns into a shouting match nobody can score — the mode is one chip and one selector away from being withdrawn. If hosts write prompts nobody can act, the door grows a length or shape rule rather than closing.
Also: a screen naming a line nobody wrote is invisible to typecheck, to the
tests and to the language check, and shows up only as GAMES.CHARADES.PICK.KEEPITSECRET
on a phone. The game's own test now reads the keys off its folder's source and holds them against
its copy.en.json, so both sides are derived and the check cannot agree with the
mistake.
Decision: two calls. (1) The standings sit above the board on
every screen — the same shared row a phone shows, following the turn — and the rail beside the
board taken earlier the same day is gone, the column presentation deleted rather than left unused.
On a big screen the row takes the PAGE's width while the board keeps its own, because a tall board
is narrow and a full table's pills were being cut off mid-name beside empty margins; it still
scrolls and follows the turn when even that is not enough.
(2) setup() picks the grid from how many people are seated and writes
it into the state: a duel plays 5×7, three to five play 6×9, six to eight play
7×11. Nothing else in the game knows a size — a cell's critical mass is counted off its
neighbours on the board in hand — so every mode and the server agree by reading the same two
numbers. The table's own size stays 2–8.
Why: the board IS the game's length, and one board cannot serve both ends of the table. Played out, a match takes roughly two turns per cell whatever the table size: two players on the old middle board spent a dozen turns nowhere near each other, and eight were wiped out before anyone had built anything. The three sizes land a duel at about 60 turns, a middle table at about 100, and a full table at about 145 — a dozen minutes at most, and a good twenty turns each. It is not a host setting because nobody choosing a game knows what 6×9 means, and a wrong pick is a bad party.
Rejected: a host setting for the board size (a question with no informed answer, and one more thing between the room and the game); one board for everyone (the fault being fixed); the largest board at 8×12 (96 cells ran past 15 minutes and gave a phone eight-point-narrower cells for nothing).
What would reverse it: real tables telling us a size drags or ends too fast — the tiers are three numbers in one place. The rail comes back only if a future board is short and wide enough that the room beside it is genuinely wasted.
Also: the room's toasts now rise while a game draws its own turn line under
its board (useToastLift), because "Cara is out" landed on top of the turn pill. Raising
the band for every screen was tried first and buried the Rematch buttons on the winner screen, so
the screen asks for the room it needs and the band drops back when that screen leaves.
Decision: three calls, taken together for Chain Reaction's 6×9 grid.
(1) The shared board-size rule (ui/boardWidth.ts) gains a third kind,
tall, which takes the board's own shape (its width divided by its height) and hands
back the widest it may be given the room left under the header — so the HEIGHT decides, on a phone
and on a desktop alike. Square and wide boards are untouched, to the pixel.
(2) The shared standings (ui/StandingsRow) gain a second
presentation: the same pills stacked in a COLUMN. On a phone Chain Reaction keeps the one row that
scrolls sideways; every big screen puts that column in a rail on the left of the grid.
(3) A game may now say how long the room should WAIT for its board before the
shells toast its line (rules.announceDelayMs).
Why (1): the grid was being sized as a square, so at 1280×800 it was measured against the width, came out too tall, pushed the turn card off the bottom of the screen and left the right half of the page empty. A board that is one and a half times as tall as it is wide simply cannot be budgeted by width — whichever way the window is shaped, one dimension runs out first, and for a tall board on a landscape screen it is always the height.
Why (2): with eight players the seat strip wrapped onto a second line, which is the fault Ludo was fixed for in September and which the shared row already solves — a single line that scrolls sideways and glides the player on the clock into view. A big screen has the room to show all eight at once, and a tall board leaves plenty of width beside it, so there the row becomes a rail. It is one component with two presentations, exactly as a sheet on a phone is a dialog on a desktop; two components could disagree, and this pair cannot.
Why (3): the room's toast was the one part of the screen with no way to wait for the board. A game whose move plays out — a cascade, a token walking — settles its rules the instant the move is made, so an immediate toast says "Ben is out" while Ben's orbs are visibly still there. That is the same two-moments-in-one-frame fault the presented state exists to remove, one layer further out, and the shells cannot know a game's timings, so the game says.
Rejected: a literal max-width in the game (eleven boards, eleven copies of one rule, and the reason the shared helper exists); a second, Chain-Reaction-shaped sizing helper beside the shared one (two rules to keep in step by hand); a game-local column of pills (a second standings that would drift from the shared one the first time either changed); a third layout for tablets (there are two layouts and a tablet is a big screen); leaving the toast early and living with it (it contradicts the board for a whole second, which is exactly what a player notices); a fixed delay in the shell (it would be wrong for every game that does not animate).
What would reverse it: (1) and (2) go the day a board's shape stops deciding its size — a different rule about screen room entirely. (3) goes if the shells ever learn a game's presentation length another way, in which case the wait is derived rather than declared.
Decision: Spy Grid is no longer offered to players. It disappears from the
home shelf, the Games page, the shelf's search, the same-device set-up screen, the online lobby's
picker and the LAN host's picker, and it loses its page on the website — no
/games/spy-grid, no sitemap line, no llms.txt line, no card in any "more games" rail.
Nothing is deleted. The folder, the rules, the two test suites, the poster, the
words, the catalog entry and the registrations in both the client and the Convex registries all
stay exactly where they are, and every gate keeps covering them. The whole park is the id
'spy-grid' sitting in PAUSED_GAME_IDS
(apps/app/src/games/pausedGames.ts); taking it out is the entire way back. The
reasoning and the resume checklist are in
spy-grid-pause.html.
Why: Karim, 17 Sep 2026 — "it is not too interesting with fewer people. Maybe when I have so many people later, I will consider adding that back." Spy Grid is two teams, a spymaster each, and a room arguing about what a one-word clue meant; the arguing is the game. At its four-player floor each team is one spymaster and one guesser, so there is nobody to argue with. Meanwhile it advertises seats for twenty, which reads as generous and draws in exactly the small groups it cannot serve. A game that is browsed by four people, picked once and remembered as the night that fell flat costs more than a game that is not on the shelf at all.
Rejected: deleting it (it is finished and tested; the crowd is what is
missing, not the game, and a repo is not where you throw away a night's work); raising its
minimum from four to eight (it would still sit on the shelf teasing every smaller group, and
a game that refuses to start is worse than one that is not offered); redesigning it for
four (the clue-and-argue loop does not shrink — that is a different game wearing the same
name); a "needs a big group" label on the shelf (a real idea, but it is a shelf feature
that earns its place only once more than one game needs it, and building it now to justify keeping
one game is the tail wagging the dog); a flag on the game's own meta (the meta is the
game describing itself, which is a different question from what the product chooses to offer this
month — and game.ts is bundled by Convex, so a shelf decision has no business in
it); refusing the game server-side too (the park is a shelf decision taken in the app, the
same as the paused LAN mode; leaving Convex alone means a room already mid-match is never broken
and the return needs no backend deploy).
Why a list and not a boolean per game: it copies the shape that already
worked once — LAN_MODE_ENABLED in src/modes/launchModes.ts, one plain
value readable from render, from a build script and from a test with no provider and no async.
Two readers act on it and between them they cover every surface: playableGames()
(which every picker already called) and CATALOG_GAMES (which every public page is
already built from). That is why hiding the game touched no picker, no page and no route.
What would reverse it: groups of eight or more regularly opening the app together. It waits on how people play, not on a fix, so there is no date.
Decision: three changes to the Quick Draw word pack, together.
(1) The level says how hard the word is to GET ACROSS in a sketch, never how rare
or long the word is. Easy is one object with a silhouette anyone names on sight (cat, key,
ladder); medium needs two or three parts or a small scene (lighthouse, birthday cake, treehouse);
hard is abstract, a scene, an action, or a word easily mistaken for its neighbour (earthquake,
gravity, recycling, orchestra, traffic jam). (2) 900 words, 300 per level, spread
across animals, food, household, nature, transport, sport, music, clothing, tools, buildings,
weather, jobs, actions and — for hard — feelings and concepts. (3) A turn spends ONE
word. deal offers three unspent words and only the word the drawer picks (or
the medium one the clock picks for them) joins the room's memory; the two they passed over go
straight back in the bag and may be offered again later. A level with nothing left still forgets
its own history and starts over.
Why: Karim played TestFlight build 31 and found the easy words hard to draw and the hard ones easy. The old 240 words were sorted by how common the WORD is, which is a different question entirely — so "hard" had filled up with wizard, pirate and mermaid, which are costumes any child draws in twenty seconds, while "easy" held things like scissors and a fingerprint. And because every turn marked all three offered words as used, a room burned three words a turn: the 48 hard words were gone after sixteen turns, and the level started repeating itself inside a single evening. Spending one word a turn makes the pack last three times as long for free, and 300 per level is what carries a group of friends across many parties before anyone sees a repeat — the recurrence test, not one session.
Rejected: keeping the old list and re-labelling it (240 words is too thin for players who come back weekly, whatever the labels say); a fourth "very hard" level (the drawer already chooses from three, and a fourth choice slows the one moment that is on a clock); letting the drawer re-roll the three words (same effect as spending three, and it hands the drawer a way to stall); generating words with AI per room (unrepeatable, unreviewable, and it puts a network call inside a turn); keeping the three offered words out of the pool for a few turns (state whose only job is to coordinate two other things — the simpler rule is that a word is spent when it is drawn).
A second reader checks the levels: the whole list goes out as a spreadsheet with the level rules attached, and comes back marked up. We take the re-filing. Ten easy words move up (traffic light, sailboat, helicopter, fingerprint, piggy bank, paint roller, pencil sharpener, toolbox, dartboard, ice skate), sixteen medium words move down to easy (the kitchen and bathroom ones — egg carton, frying pan, oven mitt, toaster, salt shaker, bathtub, shower, toilet, doormat, dustpan, night light — plus snowman, dinosaur, dragon, unicorn and mermaid, which any child draws), and thirty-six hard words turn out to be plain objects that only sounded difficult: twenty-two drop to medium (protractor, stethoscope, sundial, corkscrew, safe, shadow, smoke, eclipse, galaxy, sunbeam and the rest) and fourteen to easy (funnel, whisk, spatula, ladle, tongs, stapler, clipboard, padlock, doorknob, gear, lever, bubble, spark, sieve). We drop eight words nobody can draw — homecoming, lie, truth, wisdom, knowledge, honesty, tradition, luck. Eight new ones come in: keyhole and zipper at easy, traffic cone and hammock at medium, queue, homesick, distraction and concentration at hard. All the other suggested additions are already in the pack, too close to a word that is, or longer than two words. Because the re-filing pushes so much weight downward, sixty-two more words move back up to hold 300 a level: twenty-two easy words that are really two or three parts (tape measure, xylophone, seesaw, roof, beach, island) become medium, and forty medium words become hard — landscapes that all look like each other (desert, forest, canyon, glacier, swamp, oasis), places that are really scenes (farm, playground, stadium, gas station, ice rink) and parts with a confusable neighbour (staircase, propeller, seatbelt, lily pad, haystack).
What the review gets wrong, and we keep: cowboy and pinata as culture-specific — both are recognised everywhere and fun to draw. Sixteen "near-duplicate" pairs — cake and birthday cake, chair and rocking chair, chair and wheelchair, clock and alarm clock, door and revolving door, sliding door and trapdoor, basket and picnic basket and laundry basket, hat and top hat, box and lunchbox and juice box, phone and telephone booth, bed and bunk bed, fish and fishing rod, ball and balloon. Each pair is a different drawing and a different guess, so both stay; no word is deleted as a duplicate. Box and toolbox sit at different levels now, which settles the only pair worth a second look.
What would reverse it: players finding the hard level unguessable rather than merely hard — the fix then is re-filing words downward, not abandoning drawability as the axis. Or a room that plays long enough to exhaust a level, which would argue for growing the pack again rather than for changing how a word is spent.
Decision: the whole app has exactly two layouts. Below 768px, the phone.
From 768px up, the big screen, for tablets and desktops alike. There is one breakpoint in one file
(ui/useBreakpoint.ts). Inside the big layout nothing jumps at a second width: the page
frame is the whole window up to 1280px, then 84% of it, up to 1760px; a square board is the
largest the column and the window's height allow; a flowing board takes 60% of the window between
920 and 1200px; Quick Draw's canvas is the largest square that fits between its rails and under
the header; card grids take as many cards across as the measured width holds. Fonts change in the
one step between the layouts, never with the window.
Why: Karim's call. A tablet in between the two was a third design nobody looked after: Quick Draw showed a phone layout with a huge canvas on an iPad and pushed the guesses off screen, and the setup screens kept a phone stack up to 1080px. And fixed caps (a 980px play column, a 760px canvas, a 1280px home) left a 1920 or 2560 monitor with a small island in the middle. One breakpoint and fluid sizes mean every width gets the design that was actually reviewed.
Rejected: keeping a tablet tier (three designs to review for every screen, and the middle one kept breaking); fixed maximum widths per tier (the island); sizing Quick Draw's canvas as a share of the window's width (tried: on every landscape screen the height decides first, so a width share only ever drew the same canvas or a smaller one); scaling fonts with the window (text on a 2560 monitor would read as a poster).
Would reverse it: a screen whose big layout genuinely cannot fit a 768px-wide window even with fluid sizes, where a third arrangement is the only honest answer. Say so for that one screen; do not bring back a tier for all of them.
Decision: the word's level is the DRAWER's reward. Easy 100, medium 150, hard 200 is what the drawer earns when every guesser gets it. The first right guess earns the drawer half of that; the other half grows with the share of the remaining guessers who get it too, in tens (a hard word got by 1 of 4 pays 100, by 2 of 4 pays 130). A drawing that lands with anyone is never worth less than half, so choosing hard is a gamble, not a punishment. That number sits in green under the level on each choice card, which only the drawer sees. Guessers always race for the same 100 by the order they get it: 100, 90, 80, 70, 60, 50, 40, then 30 for everyone after, whatever the word, so a late right answer still counts. On one phone the marked guesser gets 100 and the drawer the word's full value; "Nobody got it" scores nobody. Online and one phone show the same card numbers. Undo on bare paper is now a harmless nothing in the rules, so the tools never grey out.
Why: Karim's call. Choosing a hard word is the drawer's risk, so the reward for it belongs to the drawer, and the choice becomes a real trade-off: more points, fewer friends who get it. Guessers do not choose the word, so paying them by level would reward luck. Order is easy to follow at a table ("first gets the most") where seconds on a clock are not. Round numbers in tens read like a party game rather than a spreadsheet.
Rejected: the first model (up to 10 for time left plus 3/2/1 for order, drawer up to 10), which read as small arbitrary numbers. Paying guessers by level scaled by speed, the first draft of this change, which rewarded guessers for the drawer's choice and needed a clock to explain. A separate order bonus on top of time (two rules where one does).
What would reverse it: drawers always picking hard words anyway (then the gap is too wide), or tables wanting the speed of a guess to count, not only its place.
Decision: every path that deletes a player row (Remove in the lobby or mid-match,
a guest's Leave, the host closing the room) goes through evictMember, which schedules one
internal action. It calls POST /room/<roomId>/kick on the relay with
{ playerId } and a kick pass: the same HMAC signing as the join pass, with a purpose field,
1-minute expiry, bound to room and player. The relay closes that player's sockets with
4003, and the phone does not reconnect until its screen holds the channel again. It is skipped when the backend has no
LIVE_HTTP_URL or LIVE_SECRET, and fails silently. The stale-room reaper sends
no kicks. In the same review the relay gained: a socket sending more than 80 messages in one second is
closed (1008), dropped messages no longer count as activity, a player holds at most 2
sockets (a third closes the oldest, 4001), a room wipes its storage when its last socket
goes, to targeting was deleted (nothing sent it), and LIVE_SECRET is declared
required in live/wrangler.jsonc.
Why: a security review showed a tampered app of a removed player could keep listening for as long as it held its socket, because the pass is checked only at connect. The entry below planned the kick "with the first long-held feature". Waiting made a real hole depend on every future feature remembering it. The flood rule exists because Cloudflare counts every incoming message: one scripted socket at 1,000 a second would spend the free 100,000 requests a day in about half an hour, for every room, while a dropped-but-connected socket kept its room awake.
Rejected: re-checking each pass on a timer (built and removed before: a second
socket per phone near expiry, the most bug-prone code, and still not instant). Asking Convex on each
message (a Convex call per pen point). A deploy-script wrangler secret list check
(wrangler's own required-secrets declaration refuses the deploy and names the secret, and a relay
whose secret is too short answers "relay not configured").
What would reverse it: Convex gaining a way to push to the relay without an action per removal, or the relay moving inside Convex.
Decision: a separate relay, g4g-live (a Cloudflare Worker in
live/, at wss://live.gamesforgroup.com), passes short-lived messages between
the phones in a room. One Durable Object per room (named by the Convex room id, which is unique where
a 4-letter code is reused), SQLite-backed so it runs on the Free plan, holding its sockets through the
WebSocket Hibernation API. It stores nothing about the game. A socket gets in with a ticket Convex
signs (live.ticket: HMAC-SHA256 over room, player and a 2-minute expiry with
LIVE_SECRET, only for a player row the server finds in that room). The relay checks the
ticket once, when the socket opens, and stamps who sent each message from it, never from the message.
A message reaches every socket of every other player, never any socket of the sender (a player can
hold two for a moment: two tabs, or a reconnect). Caps: 8 KB a message, 40 sockets a room, 40
messages a second a socket, and a room with no messages for 3 minutes closes (a connect into a room
that is already open is not activity). Convex stays the only source of truth: a finished stroke is
still a game action. A board sees the channel as an optional live prop, holds it only
while it needs it (open while held, a 2-second linger, closed in the background, silent retry with a
growing wait), and must play fully without it. Quick Draw sends the drawer's pen about ten times a
second during a drawing turn and every other screen draws the line as it grows.
How a removed player is cut off: Quick Draw holds the channel only during a drawing turn, and every phone, drawer and guessers alike, lets go between turns. The 2-second linger runs out long before the 5-second reveal and the word choice end, so each turn opens a fresh socket with a fresh ticket, and a removed player is refused at the next turn at the latest. The normal app also lets go at once, because removal takes the player's screen out of the room. A future feature that holds the channel for a long stretch (reactions in a lobby, mascots) gets a Convex kick: on removal, Convex tells the relay to close that player's sockets at once. Not built yet. It gets built with the first long-held feature.
Mode matrix:
| Online | Same device | LAN (paused) | |
|---|---|---|---|
| Web / native app | The shell opens one socket per room while a board holds it; closed in the background | No channel: one screen, nobody else to show | No channel for now. The host's own socket could carry these messages later, with no internet |
| Convex | Signs tickets for room members; still the referee for every move | Not involved | Not involved |
| Worker | Relays within the room; stores nothing | Not involved | Not involved (offline by definition) |
Why: a line appearing only when the pen lifts reads as a slideshow, and the fix is dozens of small messages a second that nobody needs kept. Writing those through Convex would be a mutation, a stored write and a re-run subscription for every guesser, ten times a second, per drawer: exactly the hot-document traffic the spike rules forbid. A relay keeps that traffic off the database entirely and each room is its own object, so one busy room never slows another.
Cost (Cloudflare docs, September 2026: pricing, limits): a Durable Object is billed as 128 MB for every second it is awake, and a stream of pen messages keeps it awake (hibernation only saves the idle time). The Free plan gives 13,000 GB-s a day (about 29 awake room-hours) and 100,000 requests a day (20 incoming socket messages count as one); past that, calls fail until midnight UTC. Roughly 1,250 drawing turns a day free; on the paid plan about $5.90 per 1,000 awake room-hours of the pen ($5.60 of it awake time, the rest its messages). That is why the channel is open only during a drawing turn, closes between turns and in the background, and why the pen batches at 100 ms rather than every frame. When the relay is down or over the limit the client says nothing and retries with a growing wait; the game carries on with finished strokes only.
Rejected: sending the pen through Convex mutations (write and fan-out cost per point, and it collides with the spike rules). A paid realtime service such as Ably, Pusher or Liveblocks (a new account, a monthly bill and another vendor for something a few hundred lines do). Peer-to-peer WebRTC (needs a signalling server anyway, TURN servers for phones on mobile networks, and a mesh does not scale to a room of 20). Putting the socket in the Worker without Durable Objects (a plain Worker cannot hold a room's sockets together). Expiring passes with an overlapping swap, where the relay closes each socket when its ticket runs out and the phone opens a second socket with a fresh ticket just before (built, then removed: a second connection per phone near expiry added the most bug-prone code for protection the turn-by-turn connection already gives. For long-held features a Convex kick is simpler and instant).
What would reverse it: Convex shipping a cheap ephemeral broadcast that skips storage. The free allowance becoming a real bill at scale (then either pay, which is small, or thin the pen further). LAN coming back, where the host's socket would carry the same topics and the relay is simply not used. Something private going on the channel, which would need removal to take effect at once for every feature, so the Convex kick would be built first.
Decision: a fill is a mark in the turn's list beside the strokes, holding only
the tap and the ink ({kind:'fill', x, y, colorIdx}, the same 0..1000 square). The rules
check it like a stroke (inside the canvas, a real ink, under the mark cap) and Undo and Clear treat
it like one. Every screen lays the marks onto a 500×500 grid in order and floods from the tap,
side to side, stopped by any stroke and the canvas edge, then paints the region as one path of
rectangles under the strokes. A tap on a line, or on paint already that colour, sends nothing. On
one phone the drawer now scores 5 when someone is marked as getting it (the guesser's flat 10
halved, the way an online drawer earns half when half the table gets it), still nothing on
"Nobody got it", and Undo stays on that reveal. Black and white joined the inks.
Why: every guest must see the same fill, and a phone and a browser draw a line's soft edge differently, so a fill read off each device's pixels could seal on one screen and leak on the other. Plain arithmetic over the marks gives the same answer in Hermes and every browser. The grid's walls are drawn a touch thinner than the line, so the blocky edge tucks under the stroke, which is also why a fill can simply sit under every stroke. A fill on a busy canvas takes about 5ms in a browser and about 25ms on the simulator's debug build.
Rejected: flood filling the device's own pixels (screens would disagree, and the rules could not replay it). Skia's paths and image fill (native only; the web build ships no Skia). Storing the region in the room state (a large path per fill in the document every guest syncs, and a derived answer stored where it can go stale). Letting a stroke of the same colour as the paint around it leak (every stroke is a wall, whatever its colour).
What would reverse it: fills feeling slow on real low-end phones (drop to a 250 grid, or cache the grid between taps), or players wanting a bucket that recolours a line.
Decision: on one phone the drawer takes the phone at a plain hand-over, picks one of three words, and taps Start with the word off the screen. The table shouts; the phone taps who got it first, or nobody, and that ends the turn. Only that answer scores, a flat 10 (the top of an online guess's time points). The drawer scores nothing there for now. The reveal waits for Next, with Undo beside it, which resumes the drawing with the clock it had. Letter hints are not asked on one phone, and its bar shows no blanks, only a hold-to-see button for the drawer.
Why: Karim's call. Tapping everyone who got it, in order, asked the phone
holder to referee a shouting table. One tap for the first shout is how the room already plays.
The mechanisms are Trivia Clash's one-phone award (award with a player or
null, unaward, next) and Word Search's hand-over.
Rejected: keeping the shielded hand-over (the secret words come after it, not on it). Auto-advancing the reveal after five seconds (Undo would race the timer). Time-based points on one phone (the phone holder's reaction is not the guesser's speed).
What would reverse it: Karim deciding the drawer should score on one phone, or tables wanting second and third places back.
Decision: the header over a running game shows every control by default
(name, then sound, theme, "?", Players, Leave or End game). A game may declare
meta.header: 'compact'; on a phone (under 768) that header becomes the name plus one
round "more" button whose dropdown holds the same items in three groups: sound and theme, how
to play and Players, then Leave or End game in pink. Each viewer sees only what the full header
would show them. Tablets and desktops keep the full row whatever the game says. The header
owns the sheets and the exit confirmation, so the shells pass data (who is in, what leaving does)
rather than buttons, and the same props draw either layout.
Why: the two-row phone header costs a board about 60pt of height. Most boards have that to spare, and every control one tap away is the better default (Karim, 2026-09-14). A drawing canvas does not. Tablets and desktops have the room, so hiding controls there would only cost a tap.
Rejected: compact for every game (an extra tap for sound and Leave on screens that do not need the space). Per-game custom headers (every game drawing its own chrome is the drift the one PlayHeader exists to prevent, and Boards never mount chrome). Letting each shell pass its own buttons as children and hiding them in compact (a menu cannot hold a button whose confirmation unmounts with the menu; data in, one owner of the sheets, dissolves that).
What would reverse it: most games opting in (then compact becomes the phone default), or players failing to find sound or Leave behind the menu.
Decision: the drawer picks one of three words (easy, medium, hard) against a 15-second clock, and the clock picks the medium one. Guessers see the word as blanks with letter counts, and with hints on a letter appears at half and three quarters of the clock (never more than a third of the word, never a whole word). A wrong guess is in the feed for all, a right one only says who got it, one letter off tells only its guesser. A guesser scores 2 to 10 for time left plus 3/2/1 for being first, second, third; the drawer scores up to 10 for the share of the table that got it. The host picks rounds (1–5), draw time (60/80/120s) and hints on the start sheet. Words never repeat in a room (carryOver). On one phone the table shouts and the drawer awards; the choosing clock starts when the drawer takes the phone, and the drawer's word stays off the shared screen behind a hold-to-peek.
Why: one fixed word with Start/Skip gave the drawer no say, gave guessers nothing to work from, and ran one round per player with no options. Skribbl's loop is the one players already know.
Rejected: keeping Skip (choosing replaces it). The old 3/2/1-by-order scoring
(speed did not matter). A second platform timer for hints: one deadlineAt always
holds the next moment and each tick arms the one after, so hints need no new plumbing. Showing
the word on the shared phone while drawing (the whole table could read it).
What would reverse it: players finding three choices slow, or hints giving words away at small table sizes.
Decision: when the host taps Keep out on a knock, that device's session is noted against that room, and its next knock there is refused: the guest sees "Not this time", also when they open the code again. Only the session id is noted — not the name, not anything else about the device — so a new browser, another device or a cleared app is a fresh knock the host answers again. The note is per room and dies with it (the reaper drains it in bounded batches). Remove on a seated player is unchanged: out now, may knock again. There is still no Ban button. This supersedes the part of the entry below that says Keep out records nothing.
Why: Karim: "If the host did not allow them, they should not be able to knock again from the same session." Without it, one guest can knock, be turned away and knock again all evening, and the host's answer means nothing. The session is the one identity the server really holds, and stopping the same session is a promise it can keep. It does not pretend to stop a new browser, which is why it is not presented as a ban.
Rejected: barring the name too (anyone else who happens to type that name is locked out, and the person just types another); a time-limited refusal (a guest turned away at a party should not get a second try ten minutes later from the same screen); bringing Ban back for seated players (the same promise it could not keep).
What would reverse it: hosts mis-tapping Keep out on friends often enough to complain — then an undo on the host's side, not a weaker refusal. Guest accounts would let the note hold on to a person instead of a session.
Decision: the host has one way to take someone out of an online room:
Remove. The person is out, their seat is free, and they may knock again with the same
code — the host lets them in or turns them away again. The Ban button, the room's ban
list (roomBans), the ROOM_BANNED refusal and the guest's permanent
"this code won't work for you again" screen are all gone. Keep out at the door now means
"not this time" in the same way: the knock goes, nothing is recorded, and the guest's waiting screen
says the host didn't let them in.
Why: a ban could not be kept. A guest is an anonymous device, so a banned person knocks again from a private window or another browser, and Convex never shows us a network address to bar instead. The name half of the ban was just as thin — type a different name. A control that looks like protection but is not is worse than none: the host believes the door is locked when it is not. The protection that does work was never the list, it is the host's tap — nobody sits down without it.
Rejected: device fingerprinting (unreliable, invasive, and a new browser still beats it); IP bans (not available on Convex — mutations never see the caller's address); requiring guests to sign in (kills the one-tap join a party depends on, for a problem no host has reported); keeping Ban with an honest warning (a button that needs a disclaimer is the wrong button).
What would reverse it: guest accounts. Once a guest has a real identity, a ban has something to hold on to, and it comes back as a list keyed on that identity.
Decision: every online room created, match started (with its seats) and match
played to a result bumps a counter in playStats, inside the same mutation that did the
thing. One row per UTC day, game and shard; each write picks one of eight shards at random. Karim
reads the totals by running the internal stats:summary from the Convex dashboard. Rows
are kept 400 days by a daily, write-budgeted reaper. Same-device play never reaches the backend and
is not counted.
Why: Karim asked where he can see whether anyone other than him plays. The backend already knows the moment a match starts, so counting there needs no third party, no consent and no app release. Shards keep a spike from queueing every start of a popular game behind one document (CLAUDE.md spike rule 1). An internal query costs nothing when nobody runs it.
Rejected: reading rooms and gameStates (the reaper
deletes a room thirty minutes after it goes quiet, so they only show who is playing right now). A
public stats page or app screen (a whole-app reactive query every visitor would re-run on everyone
else's traffic, spike rule 2). One unsharded counter row per game per day (a hot document at peak).
PostHog now (see the visitor-counts entry below: no players yet to ask event questions about).
What would reverse it: PostHog near launch, if its match events answer the same question more cheaply. Or a need to count same-device play, which would have to come from the phone and so belongs with that analytics tool, not here.
Decision: the website counts visits with Cloudflare Web Analytics in manual
snippet mode: no cookies, no tracking across sites, no personal profile. build-site.mjs
writes the beacon into every page of the shipped site and the CSP allows exactly its two hosts
(static.cloudflareinsights.com for the script, cloudflareinsights.com for
the report). The privacy page says so. The native app counts nothing.
Why: Karim wants to know whether anyone other than him visits. That question
needs page views, not events or people. Cloudflare already serves the site, it is free, and it
needs no cookie banner. The snippet lives in the site builder rather than the app's
+html.tsx because that file also renders Metro dev, dev exports and the offline LAN
guest bundle, none of which may count or reach the internet.
Rejected: Cloudflare's automatic injection (it adds a script our CSP was never told about and our build checks cannot see, so it was blocked on every page). PostHog now (product events and funnels answer questions only real players raise, and there are none yet).
What would reverse it: near launch, when we need to know what players do inside a game rather than whether anyone arrived, PostHog joins or replaces it, with the privacy page rewritten in the same change.
Decision: gamesforgroup.com and the docs are served by two Cloudflare Workers
with static assets only (games-for-group, g4g-docs). The site ships with
one manual command, bun run deploy: build, check, wrangler deploy. The
routing and security rules are generated into the build output by build-site.mjs
(_redirects, _headers, 404.html) instead of living in a
host-specific config. Phase A (workers.dev) and phase B (the domain) are done; phase C removes
Netlify (steps in releasing.html).
Status: phases A and B shipped 2026-09-14.
www is a redirect, not a copy: a separate two-line Worker answers
www.gamesforgroup.com with a 301 to the apex. Serving the site on both names was the
first plan, but Google sign-in only returns to the apex (SITE_URL and the redirect
guard in convex/auth.ts) and the browser keeps the sign-in's state per address, so a
sign-in started on www could never finish. A Cloudflare redirect rule would do the same without a
Worker, but the wrangler login cannot create zone rules, and a rule lives in the dashboard where
the repo cannot see it.
Why: the account is on Netlify's legacy free plan, which has hard stops, not bills. September's build minutes hit 100%, and going over suspends every Netlify project on the account, not just this one. The same plan has a 100 GB bandwidth stop, so one viral video would switch the site off. Cloudflare serves static files free with no bandwidth cap.
Found on the way: the security headers in netlify.toml (the
frame, CSP and permissions rules) were not on the live site. Manual netlify deploy
uploads skip the toml. Generating the rules into the output makes them ship with every deploy,
whichever path uploads it. Two rules changed shape for Cloudflare: the room page is served from a
bracket-free room-shell.html (Cloudflare cannot serve a file named
[code].html), and the /lan/* catch-all is gone (every /lan
screen is a real page, and Cloudflare rejected the rule as a loop).
What we rejected: Cloudflare Pages, the older product Cloudflare now steers static sites away from. Automatic deploys from CI now (Cloudflare's builder or GitHub Actions): a manual deploy matches the end-of-day release habit, and CI needs an API token stored as a secret. Staying on Netlify with a paid plan: pays to keep limits we do not need.
What would reverse it: Cloudflare's free static hosting gaining hard caps we hit, or needing server rendering Cloudflare Workers cannot give us. Automatic deploys come back when releases get frequent enough that a forgotten deploy costs more than a token secret.
Decision: the door reads Animals, Food, Countries, Cities, Sports, Travel, Brands, Space, Ocean, Superheroes, Hollywood, Indian Cinema. Fantasy is gone. Movies splits into Hollywood (well-known English-language titles) and Indian Cinema (real released films across five languages). Cities skips any name that is also a country. Every pack holds at least ninety words, and topic labels are copy keys. This updates the count in Word Search makes no words from a topic below (eleven then, twelve now).
Why: films were the topic the AI box got wrong, and they are exactly what a group asks for. A hand-checked list gives the room its film night without a model guessing.
Rejected: one Movies pack (Karim split it: a Hollywood list and an Indian list each stay titles that group actually knows). Fantasy, which was not in his list.
What would reverse it: hosts asking for a film topic by name often enough that a checked generator earns its way back.
Decision: on the dark stage a found word's capsule is the finder's colour at 55% (40% on the light ground), and the letters on it stay the normal light ink. The band draws once, then rests as a plain line.
Why: a solid band under dark letters shouted, and where two found words crossed, the later band hid the earlier one. Half strength lets both show.
Rejected: the solid band in the finder's full colour with dark letters on it (too strong, and crossing words hid each other).
What would reverse it: a player colour too pale at 55% to tell from the stage.
Decision: Word Search's door no longer has a topic box. The host picks one of
our own topics (eleven now: Space, Ocean and Fantasy joined the eight), writes their own list, or
takes a random mix. The switch is generic: a game's pack rules can say generate: false,
the shared door then hides the box, and the server refuses a generation for that game outright.
Trivia Clash keeps making questions from a topic exactly as before. The same build also makes
every board mix its directions: across, down, diagonal and backwards, on every size.
Status: shipped to the branch 2026-09-14; Convex deploy owed.
Why: on TestFlight build 29 Karim asked for one film star's movies and got words from nowhere near the topic. A word hunt with wrong words is not a worse game, it is a broken one, and the host only finds out after the grid is up. Our own lists are checked by a test and cost nothing to pick.
What we rejected: fixing the prompt. Film titles and names are exactly what the model gets wrong, and single-word, letters-only titles are a hard ask on top of that, so a better prompt would lower the miss rate without ending it. Hiding the box only on the client: a server contract the server does not enforce is a bug.
What would reverse it: a generator we can check against the topic before the host sees it (a second pass that throws out words that do not belong), or hosts asking for topics faster than we can write lists.
Decision: light is a full-colour board: yards, home columns, start squares and centre in the player's colour, white path tiles, white stars on a coloured start square. Dark is the original production board: tinted tiles, columns and yards, and every star grey. Both read from one palette per appearance, so the art never checks which is on. Tokens are one solid circle with one dark outline, and the candy pop stays only for a token resting in its yard. Choosing a token dims nothing: the movable ones pulse (a halo in their colour in light, a soft ink ring in dark) and the rest look as they do at rest.
Why: Karim, after a day of passes: "Instead of fixing the dark theme, revert it back to the production one. The problem was that the light mode was not good." And on the fades: "If we properly highlight the pulsating tokens we will be good."
Rejected: one fusion board recoloured for dark (lavender tiles, then black stars, both tried and dropped). Fading the tokens that cannot move to 55% (a faded token read as an empty well). A pop shadow on the path (made the outline look thicker on one side).
What would reverse it: the two appearances drifting far enough apart that a player switching theme no longer recognises the board.
Decision: this widens Ludo moves a lone legal token by itself (2026-09-13, below). Two tokens on one square, or several in the yard, make the same move, so the rules count distinct moves. If a roll allows only one distinct move, it plays a beat after the die is read. With a real choice, one spot glows per move and a 44 px target per cell takes the tap. Ludo, with the auto-move and the capture-only camera, was signed off the same day.
Why: "one legal token" still asked a question whenever two identical tokens sat together, which is most of the opening. A choice with one answer is not a choice.
Rejected: counting tokens rather than moves (the original rule, which asked that pointless question).
What would reverse it: a variant where identical-looking moves are not the same (a token that carries something), which the rules would then have to tell apart.
Decision: a mercy rule. A player is stuck when every token they have not finished is still in the yard. The die was measured fair, and Karim still sat 20+ rolls without a 6 while the others raced off, so from their 4th stuck roll the chance of a 6 climbs and the 8th stuck roll is always a 6. Nothing on screen says so. Everyone with a token out rolls a plain fair die, and a 6, a player leaving or a token out resets the count. Extra turns, captures and the three-6s rule are unchanged.
| Stuck misses before the roll | 0–2 | 3 | 4 | 5 | 6 | 7+ |
|---|---|---|---|---|---|---|
| Chance of a 6 | 1 in 6 | 30% | 45% | 60% | 80% | 100% |
Why: watching everyone else play while you cannot is the worst minute a party game can give someone, and a fair die makes a long wait certain to happen to somebody most nights. The first three rolls stay ordinary so a 6 still feels earned.
Where it runs: the same rules file on one phone and on Convex, drawing only on the game's own random stream, so both agree. Online needs a backend deploy before it applies there. LAN (paused) would get it from the same file.
Rejected: leaving the yard on a 1 or a 6 (a different game, and it doubles how fast everyone leaves, not just the unlucky). Starting every player with a token out (removes the opening tension for all). Pure luck (the measured-fair die is exactly what produced the complaint).
What would reverse it: players noticing the 6 always comes and timing it, or the rule making a match too short for the 10–15 minute floor.
Decision: Ludo now stands on the same shared dice kit as Snakes & Ladders. Where it differs: when a roll leaves exactly one legal move, the token moves by itself a beat after the die is read; two or more glow and wait for a tap. The board camera pushes in only for a capture, riding the captured token home. Each seat also keeps its own corner for the whole game: before, a player leaving slid everyone after them into the next corner, tokens and all, onto cells the capture rule no longer agreed with. Defaults, awaiting Karim.
Why: a tap with only one possible answer is a question the board already knows the answer to, and on one phone it is a pause the whole table waits through. A capture is the one moment in Ludo that changes the race, and it happens somewhere on a board of 52 small cells; an ordinary move is not worth moving the whole board for.
Rejected: always asking for a tap. It is the classic app behaviour and makes every turn two taps, most of them with nothing to decide.
Rejected: zooming on every move, as Snakes does for every ladder and snake. In Ludo almost every turn moves a token, so the board would breathe in and out all game and the one moment that matters would look like all the others.
What would reverse it: players missing their own token moving because it did so without them (then a short visible pause or a tap), or a capture camera that makes a quick four-player game feel slow.
Decision: the 3D die, the strip under the board (roll line, turn pill, die), shake to roll, the one presented moment, the zoom-and-follow camera and the standings pills move out of Snakes & Ladders into shared UI. Snakes keeps only its board art, its sentences and what a roll is. Ludo places the same pieces. Nothing a player sees or waits for changed, measured before and after.
Why: Karim asked for every Snakes lesson to apply to Ludo. Each of those pieces carries a hard-won fix (the iOS resting-style rule, the queued moves, the die lead). A copy would have to re-learn every one, the 2026-08-17 duplicated-screen failure again.
Rejected: copying the Snakes board into Ludo and adapting it. Lifting only the cube (the other pieces carry more of the fixes than the cube does).
What would reverse it: a dice game whose strip genuinely differs, which would get an adapter over the kit, not a fork of it.
Decision: every turn line comes from one shared rule. On separate phones it is
about the viewer ("Your turn, Karim" for him, "Karim's turn" for the rest, plus an "again" pair
after a six). On one shared phone it stays third person. Online, only the device whose turn it is
shows the die, and only the roller loses the turn pill while their move plays. On one phone the die
and the pill are always there, decided by the shell's shared-device fact, never by me.
Why: the Snakes pill told Karim "Karimulla's turn" on his own phone. A game that draws its own turn line was writing its own wording and got it wrong.
Rejected: per-game turn keys (the source of the bug). Hiding by comparing with
me (it hid the die from the whole table on one phone, see the learning of the same
day).
What would reverse it: a game where a watcher needs the die to act, such as a bet on the roll.
Decision: a player colour keeps its bright value as a fill, but as text it switches to that colour's readable shade for the appearance, through one shared lookup (yellow on a white card goes from 1.33 to 5.02 contrast). Haptics join sound in one helper and ride the shared buttons, switches and chips: a knock on a press, a tick when a choice lands, a double on a win, a blunt one on a refused move. Nothing buzzes while things merely move. Silent on the web.
Why: "Karim" in party yellow was unreadable on the light ground. Karim asked for haptics "on the interactions that deserve it", and putting them in the shared parts means no game writes haptics code.
Rejected: darkening the party colours themselves (fills would lose their candy). Haptics per game (every game would choose differently).
What would reverse it: a colour whose ink shade reads as a different player.
Decision: the same-device Players sheet offers Remove exactly as the online host
gets it: same sheet, same rows, same "Remove Aafreen?". Any seat may go, while more than the game's
own minimum still play (so a duel never shows it). Removal runs the game's own
onPlayerRemoved. The confirmation opens over the sheet in both modes, so the
roster is still there, one row shorter.
Why: someone leaves the table and the empty seat kept taking turns. Nobody is the host at a table, so the table decides.
Rejected: a same-device-only removal flow (a second copy of the online one). The confirmation replacing the sheet (it read as leaving the list, not asking about a row).
What would reverse it: accidental removals, which would want an undo rather than a second confirmation.
Decision: in Snakes & Ladders a six keeps the die — the same player rolls again, after a ladder, a snake or a bounce alike — and a six that lands exactly on 100 simply wins, as any roll does. There is no penalty for three sixes in a row. On a phone the roll can also be made by shaking the handset, read from the accelerometer; on the website the die is still tapped. The roll line under the board now names the square the token ended on ("Aafreen rolled 4 and climbed a ladder to 18."), and before anyone has rolled that same line holds the one thing a newcomer needs — how to roll at all.
Why: the six is the rule everyone already brings to the table from childhood, and it is the only thing in the game that makes a turn feel earned rather than dealt. Shaking is the other half of that memory: at a real table you shake before you throw. The destination in the sentence means nobody has to find their token among a hundred small squares to learn what just happened to them.
Rejected: the three-sixes penalty (lose the turn, or go back). It punishes the luckiest moment in a game of pure luck, and it needs a counter in the state whose only job is to remember two rolls ago.
Rejected: announcing the extra roll as a toast over the board. The board already tells one story in one sentence; a floating second voice saying the same thing is the duplicate that sentence was built to remove, and it would cover the board while the token moved.
Rejected: shaking as a setting the player turns on. Nothing about it can go wrong — it needs two jolts inside a moment before it counts, it is deaf unless it is your turn, and it lets the sensor go the instant the board is not asking for a roll — so a switch would only be one more thing to find.
What would reverse it: a phone in a pocket rolling by itself (the two-jolt test proving too soft in real hands), or a game in this family where the extra roll makes one player's streak long enough to bore the table.
Decision: the Snakes & Ladders die is the same cube in every state. At rest it is parked at a fixed three-quarter angle with the rolled number on the top face; a throw spins it and lands it back on that exact angle. The flat rounded candy square with the thick rim, and the morph that used to fade between the two, are deleted. Which number is up is a rotation of the whole cube (a quarter or half turn that brings that face to the top, applied inside the spin), so the six numbers stay where they are, as they do on a real die. Because that turn sits inside the spin, the spin's own start and end are always the resting angle and the landing can never miss the pose.
Why: Karim slowed a recording down and read it frame by frame. The cube in the air was right; the fault was the shape it became at the end. A morph between two shapes is a confession that there are two shapes, so the fix is to delete one, not to smooth the join.
Rejected: keeping the cube fixed and relabelling which number each face draws. It lands the same picture with far less arithmetic, but the die would quietly rewrite its own faces between rolls, and the honest version costs two extra rotations in a transform that was already doing five.
Rejected: decomposing the combined orientation into three angles on the way down instead. That works, but it takes the lean and the spin axes over for the landing, so the hop, the tilt and the overshoot would all have had to be re-expressed relative to a pose that changes with every roll.
What would reverse it: a die that has to show two faces at once (a doubles game, two dice in one control), where a fixed angle stops being the whole answer.
Decision: the play shell's turn chime now plays only when the turn becomes yours in a connected mode. On a shared phone it plays nothing at all.
Why: the chime exists to make someone who has put their phone down look up. Nobody puts down the phone the whole table is playing on — there, the turn changes on the very tap you just made, so the chime was firing on top of whatever the move itself sounded like. In Snakes & Ladders that meant every single roll of the dice made two noises. Asked out loud, the honest sentence for the shared-phone version was “it makes one device feel like the other mode”, which is the sign to delete it.
Rejected: silencing the chime while a board is busy. That is a flag whose only job is to keep two unrelated things out of each other's way, and it would have left the chime firing on every tap in every game that does not animate. The question was never when it should play but whether it belongs on one device at all.
What would reverse it: a pass-and-play game where the phone genuinely leaves the table between turns — then the hand-off screen, not the turn change, is the thing worth a sound.
Decision: the Snakes & Ladders die is a real cube built in the app's own code — six faces standing in a box, each one a candy face, turning on two axes and decelerating onto the exact face that carries the rolled number. And the dice rattle is now a recorded sound file rather than something built out of tones. Karim asked for both after playing it (“the dice animation and its sound effect are not good… I would like to go for a 3D animation… in-house”).
Why in-house and not a ready-made animation. A bought animation is a fixed picture: it cannot wear the colour of whoever is rolling, it cannot follow the light and dark grounds, and every number would need its own copy of it. Ours is drawn from the same tokens as every other candy control, so the player's colour and the appearance switch come free, and the same code runs on the phone and on the website with nothing extra shipped. Rejected: Lottie — a designed file, so the colour and the theme are baked in, and six landings means six files; Rive — more capable, and it can be told which face to land on, but it is a new runtime in the app and a new tool in the workflow for one control; a picture sequence — the same baked-in problem, in more megabytes.
How it is built, and why that detail mattered. The obvious way to build a cube on the web — a parent holding its children in one 3D space — does not exist on a phone: a parent's turn does not carry its children with it, and there is no way to push a face forward out of the screen. So each face carries the whole turn itself and is pushed out to the surface of the cube by its pivot point, which sits half a cube behind it. Rotating about a point behind you sweeps you around it, which is exactly what a face on a cube does. The faces pointing away are dropped, and the three left never overlap, so nothing has to work out which is in front. That one choice is why there is a single piece of code for the phone and the website rather than two.
Why a recording for the dice. A tap or a right answer is an abstract noise
and tones are honestly the right material for it. Dice are an object: shaken in a hand and
thrown on a table, with a texture no pair of tones reaches — the tone version read as a
beep. So sound now has two kinds, and the app chooses: a name that has a recording plays the
file, everything else is built out of tones, and both go through the same call, so the header's
sound switch silences either and a screen never knows which it asked for. The tone version of
the rattle is kept as the fallback for anywhere the file cannot be loaded.
Rejected: a better synthesised rattle — we already tried, and the ceiling is low;
paid or licence-encumbered audio — the file is cut from Kenney's Casino Audio pack, which
is public domain (CC0), credited in apps/app/assets/sounds/CREDITS.md.
What would reverse it: either of these growing past a handful — a dozen recordings
would want a proper asset pipeline rather than a lookup table, and a second 3D object would want
the cube's maths lifted out of the game folder into a shared piece.
Decision: there is no ROLL button: the die is tapped. It is blank in the next player's colour between turns and shows a number only while that roll plays. The token waits until the die has landed and been readable for a beat. The board draws from one presented snapshot that advances when the move finishes, so chips, die, standings, sentence and pill change in one frame; a state arriving mid-move is queued. A ladder or snake zooms in and follows the token for as long as the ride is long; a plain step never zooms.
Why: Karim played it three times on 2026-09-13. The die held the last player's number, the pill named the next player while the chip still walked, and a long snake lost its camera halfway down. At a real table you throw the die you hold, and the answer exists only once it lands.
Rejected: a ROLL button beside the die (two controls for one act). A fixed ride time (the camera left long snakes early). Making the token wait visibly aimed at its square (it sat parked in the corner on a first roll). Zooming on every move (the board would breathe all game).
What would reverse it: a phone too small for the die to be an easy target, or a game where moves are so short the camera is only motion.
Decision: the last three games (Tambola, Quick Draw, Charades) moved their words into their own folders, so all eleven are done and the copy gate now covers every game's rules file and module — a new game is in the gate the moment it is scaffolded, without anyone remembering to add it. Two smaller calls came with them.
A game's one-liner and paragraph are written once, in the game's folder. The line-up the public pages are built from carried its own copy of both, and the two had quietly drifted apart for five games — Connect Four, Ludo, Snakes & Ladders, Trivia Clash and Word Search each sold themselves one way in the app and another way on the website. The line-up now reads the game's own file; only a game still on the roadmap keeps its words there, because there is no folder to write them in yet. The game's wording is the one that survived, and the website's second version is gone: a paragraph belongs beside the rules it describes, where the person changing the game will see it. Rejected: keeping the website's version instead — some of those read better as marketing, but choosing the further-from-the-game copy is how the drift started. Karim can reword any of the survivors in one place, and both sides follow. What would reverse it: a real need for the website to say something the app should not, which would be a new field, not a second copy of an old one.
A rules file names a line; it never builds one. Two places still did. The one-line announcement a game makes to the whole room (“Lappy claimed Early Five”) was assembled inside the rules, which run in the cloud with no idea what language anyone is reading; it now hands back a name and the holes to fill, and the play shell says the sentence. A hole may itself be a name — the prize's own word is one — and those are looked up before the sentence is filled in. And a refused move may now carry the numbers its words have a hole for, so Trivia's “host mode needs 3 people” quotes the rule rather than spelling the number out beside it. Rejected: leaving the announcement as a plain sentence and translating it “later” — it is the one line in a game that every person in the room reads at the same moment, so it is the last one that should be in English by accident. What would reverse it: nothing we can see; both shapes are the ones the winner screen's badges already used.
Decision: a game declares meta.secretTurns when a turn hides
something from the table (Quick Draw's word, Charades' prompt). Only those games get the lock chip,
the no-peeking note and "show me". Every other game gets a plain hand-over: "I'm Karim, let's go!".
Why: the pass-the-phone screen dressed every hand-over as a conspiracy, even for Word Search, where each player gets their own scramble and looking gains nothing. Secrecy is a fact about the game, not about the mode.
Rejected: one screen for all games (invents a secret the room does not have). The shell guessing from the game's shape (a declared flag is one line and cannot guess wrong).
What would reverse it: players peeking at a non-secret game in a way that spoils it.
Decision: no screen holds a sentence. The app's words live in
apps/app/src/i18n/en/*.json, one file per screen area, and each game keeps its own in
src/games/<id>/copy.en.json; a screen asks for a line by name
(play.handoff.passPhoneTo) through i18next and
react-i18next, with the device's language read by
expo-localization. Keys are typed from the English files, so a
typo or a line nobody wrote fails bun run typecheck. A lint gate
(bun run lint:copy) refuses a sentence typed into a screen, and
bun run i18n:check holds every language to the same set of lines.
Only English ships, and there is no language picker. Karim's words:
“adding a new language later is a simple process because of the work we do today.”
Status: the app's shared screens and Tic-Tac-Toe shipped 2026-09-12; the other nine games
follow.
Why keys and not sentences in the rules files: every game.ts is
bundled by Convex and run on the server for online play. A server has no idea what language the
person at the other end is reading, so it cannot be the thing that holds a sentence. A rules file
therefore carries names and codes only: a mode's tip is a name, and a refused move throws a code
plus its own reason (games.<id>.errors.<reason>, falling back to
the shared line for the code). The same applies to the backend's own refusals. This also removed a
path that was quietly hurting us: a reducer's developer note used to be shown to the player, which
is how “column out of range” and “cell already taken” reached people at the
table. Notes are now for the log; players get words a friend would say.
Why a game owns its copy: games are built in parallel, and one shared strings
file would be the file everyone edits at once. A game's words sit in its own folder like
everything else it owns, and the loader builds the games.<id> space from the
registry — so adding a game adds its words with it.
Content is a separate axis from copy. Translating the buttons does not
translate a word hunt: its grid is A–Z letters. So a game may declare
meta.languages and the pickers do not offer it to a table reading anything else —
better than offering a round nobody can win. Word Search is the first to say so.
Rejected: Lingui macros — they read beautifully in the code
(t`Waiting for ${name}`) but they need a Babel macro in the Metro pipeline, and the
words would still be living in the screens, which is the thing Karim wanted to stop.
Sentences in the rules files, translated at the edge — impossible for online play, where
the server is the one refusing the move. One central strings file per app — the merge
conflict factory, and the reason a game's copy is in the game's folder.
ICU message format (i18next-icu) — not needed: the plural sentences the app
has are English's two forms, which i18next handles on its own, so the extra dependency buys
nothing today. A language picker — nobody has asked for a second language yet; the
device's setting decides, and a picker can be added the day a language is.
What would reverse it: nothing about the structure — worst case it stays
English forever and we have a page where the whole product can be read aloud
(docs/copy.html), which was worth it on its own. The one thing that would
change is the SHAPE: today the whole of English ships inside the app and the catalog's words are
resolved when the file loads. Several languages means loading only the one in play, and building
the website once per language — both changes to src/i18n/ and the site build, not to
any screen.
Decision: the same-device hand-off screen is the only gate before a turn.
When the player taps “I'm Karim — show me”, the board is on the screen and the clock is already
running. Word Search's Go panel, its word count, the timer's empty placeholder track and the
“Found 0 of 9” progress line all go with it. The clock is started by a new contract member,
rules.onReveal?(state): A | null: the local shell asks the game for an action the
instant the gate closes and dispatches it as the seat taking the phone. Status: shipped
2026-09-12.
Why: the gate already names the player, shields the board and waits for them to say they are holding the device — which is exactly the confirmation Go was asking for a second time. One confirmation, one screen. Putting the signal in the rules rather than in the Board keeps Boards mode-agnostic (a Board never sees the hand-off screen, and must never ask which mode it is in), and keeps it reusable: any timed hot seat now starts its clock the same way. The answer is guarded on the state being protected, because the gate re-appears on a fresh mount and on a resumed match, and a turn already running must not be restarted or refused with an error the player has to read.
Rejected: the Board dispatching begin from an effect on mount —
it reads the shell's timing through a side door, fires in the online game too, and re-fires on
every remount; and keeping Go but shrinking it, which keeps two confirmations and only makes the
second one quieter. What would reverse it: a game where the player needs a beat
between taking the phone and the clock starting — a rule to read, a card to memorise — which is
exactly the case onReveal returning null already serves.
Decision: on the shared phone the row above the board shows every player in seat order with their count — turns already played, the turn in progress, and the seats still to come at nought — with the player holding the phone highlighted the same way the online board highlights “you”. It reads Standings. Status: shipped 2026-09-12.
Why: it named only the phone-holder, which made one phone feel like a different game from the room. A player hunting needs to see the number they are chasing, and the score they have to beat is the whole point of going second. Seat order, never sorted by count: the row sits directly above the board, and a row that re-ranks itself on every find slides the board under the player's own finger.
Rejected: best-first order, as the online round uses — right for a settled round, wrong for a turn in progress; and a second scoreboard under the board, which is the scoreboard the round sheet and the Players sheet already are. What would reverse it: a table big enough that the row wraps to three lines on a phone, which would put the board below the fold.
Decision: on the shared phone, tapping Go changes only what is inside the board's box. The Go panel is the same square the letter grid will be, the countdown bar holds an empty track until there is a clock to count, and the word list is drawn from the first moment — so on a shown board the player waiting to start reads the very words they are about to hunt. The grid itself is still stripped until Go: not one letter, and no word's position, is sent early. Status: shipped 2026-09-12.
Why: the play column is centred, so anything that arrives late pushes the board upwards — the board jumped about 115px at Go. Nothing around a board moves, and the way to settle that is to give every part its full height from the start rather than to animate the difference away. Showing the list early costs nothing: it is public on that board anyway, the clock has not started, and the grid is not there to hunt on.
Rejected: letting the Go screen keep its own comfortable shape and easing the jump — a transition that admits the layout is wrong; and blanking the chips before Go with placeholders, which wrap differently and move the board by a different amount. What would reverse it: a hot-seat rule where reading the list early is worth something — a scored memorisation round, say.
Decision: Word Search now plays on a shared phone, and it plays a different
shape there. One round, one turn each, in seat order. Every player gets their own
scramble of the grid; a start-sheet option says whether that is the same list of words for
everyone or a fresh set each. After the shell's pass-the-phone screen the player sees how many
words are hidden and a single Go — the board is not on the screen at all until they tap
it — and their clock starts on that tap. The turn ends when the clock runs out or they have found
every word, and the phone moves on. Most words takes it; among the players who cleared their
board, the one who got to their last word quickest; equal counts share the place. The start sheet
asks the couch only what the couch means: Words (same for everyone / new for each
player), board size, word list, and a Turn length of one minute by default, stepped in
half minutes up to five. Rounds and Race/Parallel are not asked there at all. The online game is
unchanged.
Mode matrix: online, the match lives in a Convex room and the server is the authority; on one
phone it lives in the same-device Zustand session, which runs the identical pure reducer and owns
the clock through one setTimeout; LAN does not offer the game (it is paused
anyway). The words still come through the same door — our own lists and a hand-written list need
no network on the couch; a topic written by a model needs the host signed in and online, exactly
as the quiz's same-device door already does.
Status: shipped 2026-09-12.
Why: the online game is simultaneous — everybody hunting one board at once is the whole of it — and a shared phone has no simultaneous. The honest translation of "everyone hunts" onto one screen is "everyone hunts in turn", which is the oldest party-game shape there is. Two details carry it. A board of your own, because being handed the board the last player just half-solved is not a turn. And the clock starting on your own tap, because the moment between taking the phone and being ready is not a moment anyone should be racing through — without it the player before you could hold the phone out and stare at your grid.
What we rejected: rounds on one phone — three rounds of five players
is fifteen hand-overs and half an hour, and the thing a series buys (a bad round does not decide
it) is already bought by giving everybody the same words; one shared board the whole table
hunts in turn — the second player starts on a board with the first player's finds already
crossed off and reads their answers for free, and the last player inherits a board with nothing
left on it; a countdown before the board appears instead of a Go — it makes the phone
decide when a person is ready, which it cannot know; a Word Search-shaped switch on
GameSettingsSpec — the platform already lets a field say which modes ask it
(modes: ['online','lan'], Trivia Clash's clock fields) and a module already learns
its mode through defaultSettings(mode), so the whole flow switch is one key in the
settings blob and no contract changed.
What would reverse it: if the couch turns out to want a longer sitting, the one-round rule is the first thing to revisit — a best-of over hot-seat turns is a small change, since the table machinery is still in the same file. And if players start handing the phone over and tapping Go in the same motion, the Go screen has stopped earning its step and the count belongs on the hand-over screen instead.
Decision: the topic is no longer a setting on Word Search's start sheet. Starting the game now opens the door the quiz already uses — write a topic and have the words made, tap one of our lists (Animals · Food · Countries · Movies), write your own, or take a random mix of everything we ship — and the start sheet (rounds, mode, board size, word list, round length) is the second step behind it. Tapping one of OUR lists makes no model call and spends nothing: the words already ship inside the app, and they become a pack exactly the way a written one does. A board too small to hide the host's longest word is not offered, and the pick moves to one that fits. Mode matrix: Word Search is online-only, so the door is online-only too, matching the quiz; the words travel in the match settings, so the reducer reads them identically wherever it runs — Convex today, the host's phone if LAN ever returns, the browser in a test. Status: shipped 2026-09-12.
Why: a word hunt is nothing but its words, and the four topics we wrote are four evenings. The machinery for "the host supplies the content" already existed and was already paid for (2026-08-16, below) — it was simply wearing a quiz's clothes. Putting Word Search behind the same door made the game endlessly re-playable and, more to the point, personal: a board of everybody's names at the table is a different game from a board of animals. The board-size rule is the price of that: a name is not interchangeable with another name, so quietly leaving ALEXANDRINA off the board because the host picked an 8×8 is not acceptable the way skipping one of ninety-eight food words is.
What we rejected: a word picker of Word Search's own — a second
screen doing the quiz's job, which is the exact mistake that cost us three bugs in August
(learnings, 2026-08-17); keeping the topic select AND adding the
door — two controls choosing the same thing, and the settings key they both wrote to was
already the same one; generating our own lists through the model — paying a model to
write the Food list we already ship, and making the host wait for it; dropping words that
do not fit the board — invisible, and unforgivable on a list someone typed by hand;
slicing the pack down to one board's worth of words (what playItems does
for the quiz) — the pool IS the no-repeat memory, so a match plays the whole list.
What would reverse it: a game whose content is neither a question nor a line of text — the door now has two item shapes and a third would be the moment to ask whether the writing form belongs to the game after all.
Decision: Word Search now plays the same rounds Tic-Tac-Toe plays. The two ways to claim — Race (a word is gone the moment anyone finds it) and Parallel (nobody can take one off you) — became the game's MODES: chips above the board, a pick on the start sheet, Mix dealing one of each. Whether the word list is shown or hidden stayed a setting, as did the topic, the board size, and the clock, which is now the length of ONE round. A round ends when its clock stops or the grid is cleared, and goes to whoever found the most words in it — everyone on a tie, nobody when the board was never touched — and most rounds takes the series. The pills above the board keep showing THIS round's counts, because that is what people are doing right now; the rounds-won tally stays where every series game keeps it, in the round sheet and the Players sheet. And no word comes back while the topic still has words the room has not seen: the board remembers them across the rounds of a series and across rematches in the same room, starting the topic over only when too few are left to fill a board.
Why: one grid was over in three minutes and then the night was over with it — the same problem Tic-Tac-Toe had, and the table already existed to solve it. Making Race and Parallel the modes rather than a buried setting also fixes a smaller thing: they change how the round FEELS, which is exactly what a mode chip is for, whereas hiding the list only changes how hard it is. Repeats were the other half: a room that plays three rounds and then a rematch was being handed the same animals four times, which is the most-noticed flaw in any party game played round after round.
Rejected: making Shown/Hidden a mode — it multiplies the chips (four combinations for two real ideas) and it is orthogonal to claiming, so a chip row would be lying about what it controls; carrying word counts across rounds and ranking on the total — one lucky grid would decide the night, and the tally stops being sayable out loud; a second scoreboard above the board for the series tally — two sets of numbers on one screen, and the strip above a board must never change height; reshuffling the topic every round and living with repeats — cheap, and precisely the thing the room notices.
What would reverse it: a request for one-off quick games (rounds go to 1 and the series disappears on its own, so nothing has to be undone), or topics deep enough that remembering what a room has seen costs more than it buys.
Decision: the shared table (table.ts · series.ts)
no longer assumes a duel. A round is recorded with a list of winners
(recordRound(series, winners)): one name in Tic-Tac-Toe or Connect Four, two or
more when a simultaneous round ends in a tie, none for a draw. Every name on the list gets one
round, nobody comes second within a round, and the series is still "most rounds won, equal
tallies sharing". A game also declares how its seats work — seats: 'duel' (the
default, unchanged: two players, a walk-out stops the match short, the opener swaps each round)
or seats: 'open' (two to eight all playing at once: a leaver simply drops out, the
rest play the series to its end, and the seat order never rotates). A leaver's rounds stay in
the record but they are out of the running, so "the series is already decided" is judged only
among the players still at the table. A series that falls below two players stops short, the
way a duel does.
Why: Word Search is the first game where a round is played by everybody at the same time, so two people can finish level and one person leaving is not the end of the party. The alternative was a second copy of the whole rounds-and-tally machine for simultaneous games, which is exactly the duplicated-screen failure this repo has already paid for twice. One round = one win per winner also keeps the tally sayable out loud ("Karim 2, Bea 1"), which is what the round sheet and the Players sheet both show.
Rejected: carrying points across rounds (words found, letters, seconds) and ranking on the total — it makes one runaway round decide the night, it cannot be read at a glance, and "whoever wins most rounds is the winner" is the rule Karim actually described (2026-09-11); a shared round going to nobody (it punishes both players for being equally good); splitting the table into a duel version and an open version (two files drifting apart, with no gate able to notice).
What would reverse it: a simultaneous game where the margin inside a round is the whole point — then a round needs a score, not a winner, and the tally becomes points.
Decision: a game may now hand the online shell an optimisticView:
a small pure function answering "how should this player's own action look on their own screen
while it is being checked?", computed only from what that client is allowed to see. Word Search
answers it by reading the letters under the line you dragged — the board is public, so this is no
secret — and if they spell a word on the list that is still going, the capsule fills in your
colour in the same breath as the release. The claim still goes to the server, which is still the
only thing that decides: if it says no, the fill disappears when the answer lands and the toast
says why. Measured on two players in a live room, a find now appears about 31ms
after you let go (two frames, and the first frame drawn after the release) where it used to take
about 385ms. This is Tambola's model — your phone marks the ticket, the host still confirms the
prize — made general.
Why: the earlier reading was that nothing could be painted early here, because the client is not told where the words are (deliberately — so nobody can read the answers out of the app) and because in Race mode a word belongs to whoever got there first. Both are true, and both are about deciding. Neither stops the client from making an honest GUESS about its own gesture: the letters are on the screen for everyone, the word list is on the screen, and "these letters spell a word that is still going" is a guess the phone can make on its own. A guess is enough, because the thing it draws is temporary by construction — the underlying plumbing throws every optimistic paint away the moment the real answer arrives, so being wrong costs a capsule that vanishes, not a state anyone has to unwind. It also takes work off the server's shoulders: nothing waits on the round trip any more.
Rejected: waiting for the server and merely keeping the released
outline on screen (the previous decision here — honest, but it made every correct find cost a
third of a second for no reason); a validator in the board, deciding finds in the UI —
the same guess, but living where it looks like an answer and could drift from the rules, whereas
this one sits beside the rulebook in game.ts and shares the rulebook's own
"is this word still going" test; handing every client the word positions so the real
rules could run locally — that is exactly the cheat the hiding exists to prevent, and Race would
still need the server; painting anything terminal — the preview marks a word and nothing
else, so it can never show a match ending, or a win, that the server then takes back.
What would reverse it: a network fast enough that the wait stops being perceptible (it is not), or a game where a wrong guess would paint something a player cannot afford to see undone — that game simply returns "paint nothing" and waits, which is the default for every game that says nothing.
Still true: the outline of a released line is held until the answer arrives whenever nothing filled it — a drag that spells nothing, and every match where the host hid the word list (there is no list to compare against, so the client keeps quiet and waits). The board never sits blank after a release in any case.
Decision: the grid draws its letters directly on the panel — no tile, no border, no grid lines — and every found word gets one rounded band from the centre of its first letter to the centre of its last, drawn under the letters and animated in over 300ms. The band is the colour of the player who found it, at 40% on the light ground and 58% on the dark one; the drag in progress is the same band in the dragger's colour, fainter. Nothing else on the screen changed.
Why: Karim brought a picture of the board he wanted and the tiled version was not it. The capsule also says something the tiles could not: who found the word. Two alphas because the same 40% that reads as a pastel highlighter on white drags every hue toward the purple underneath on the dark ground — party yellow came out brown.
Rejected: keeping the per-letter tiles (the thing he did not like); giving each WORD its own pastel from a fixed palette, as the inspiration does (our words are found by different people, and a player's colour follows them everywhere — a second palette would compete with it); one alpha for both appearances.
What would reverse it: boards where many words overlap on one cell, if the blended bands stop reading as separate words — then the capsules want outlines rather than transparency.
Decision: Tic-Tac-Toe and Connect Four celebrate a won round with a line drawn from the first winning cell to the last, in the colour of the marks that made it (the loser's in Avoid), with a whoosh, then the fanfare. One switch in shared UI set it, and the round sheet's timings follow whichever plays. The cell burst stays for wins that are arrivals, such as a Ludo token reaching home. This replaces the "boards' bursts" named in the next entry.
Why: Karim compared both and kept the line: it matches the shape of the win "horizontally, vertically, or diagonally".
Rejected: the burst on each winning cell for line games (it says where, not what). Keeping both behind a setting (a choice nobody asked for).
What would reverse it: a line game whose winning cells do not form a straight line.
Decision: when the rounds left cannot change the tally
(decidedWinner: a lead bigger than the rounds left), the round sheet names the
series winner and whoever runs the table sends finish — the rounds become the rounds
played, so the series ends on the ordinary path (isLastRound → the result) — or
next to play the rest. The same sheet also carries the round's celebration: the
fanfare and the boards' bursts fire when the deciding move has landed, and the sheet follows a
beat later.
Why: Karim wants the choice, not an automatic end ("do you want to continue
to finish all the rounds or end the game?") — the last rounds are still play. Shrinking
rounds keeps one end-of-series path and needs no new state; the badge then reads
"2 of 2 rounds", which is what the players chose.
Rejected: ending the series automatically once decided; a called
flag beside the rounds (a second way to be over, for the badge's sake); playing the fanfare from
the shells on the interlude edge (the sheet already knows the winner and the timing — one place
for the sound, the hold and the copy).
What would reverse it: players finishing every decided series — then the choice is noise and the rules should end it.
Decision: a game has ONE explanatory card (HowToPlaySheet over
HowToPlayBody, which the site's game page renders too) and every "?" opens it —
the picker's, the lobby's, and a third circle in the play header after Sound and Theme, which
the header owns (PlayHeader takes the game and the state, mounts the sheet itself,
and opens it on the mode in play). The "?" beside the mode chips and its own little sheet are
gone. A game brings either a prose HowToPlay or its
modes, each with its own steps (the registry refuses neither and
both); with modes, How to play is the start sheet's chip row (SegmentRow, shared
with SettingsFields) over the picked mode's numbered steps. No "Modes" section.
Why: two places explaining the same modes in two shapes is the drift the repo keeps paying for; the help belongs with the other screen-wide switches, not with the chips (Karim: "showing it next to the chips is not right"); and the old steps described Classic whatever mode was on — a how-to that is wrong for three of four modes. Steps per mode, picked by chip, fixes that and keeps the card one screen tall.
Rejected: the shells mounting the sheet (three copies of the same three
lines); a row per mode with its one-line rule (built, reviewed, replaced the same evening: it
scrolled, and could not grow to points); a separate Modes section of chips + one line under
the Classic steps (built next, replaced within the hour: the steps above it were still
Classic's); a game's HowToPlay component rendering the chips itself (the game
modules are loaded by Node tests, which cannot run reanimated — so the chips live in
ui, and the game hands over data).
What would reverse it: a game with more modes than fit one row on a phone — then the row wraps or scrolls, and the steps box stays.
Decision: the whole "modes in rounds" plumbing — the start sheet's rounds and
mode (or Mix), the plan of modes drawn at setup, the chip switch as the host's live override,
NEXT ROUND, the tally, the ranking at the end, and leaving as a right — lives once in the engine
(table.ts, series.ts, duel.ts, actions.ts).
A game brings two answers (when its grid is settled, how a fresh one looks) and its move
reducers, and spreads tableRules. The strip above the board and the round dialog are
one shared component each (ModeStrip, RoundDialog). Connect Four's modes
are Classic, Pop Out (Hasbro's: drop, or pop your own bottom disc so the column slides down; a
pop that makes four for both goes to the mover) and Avoid; Five in a Row and Power Up were
offered and declined for now.
Why: the second game copying the first is exactly the duplicated-screen failure the repo already paid for once (2026-08-17), and Fallow flagged five clone groups the moment Connect Four was written by hand. One helper means a third short game costs an afternoon and cannot drift from the other two.
Rejected: a per-game copy with a shared "pattern" (drift by design); folding the table into the mode shells (it is rules, not chrome — the server must enforce who runs the table).
What would reverse it: a series game whose round is not a duel (three or more seats): the tally and rankings generalise, the walk-out rule would not.
Decision: Tic-Tac-Toe asks two things when the host starts it: how many
rounds (1–7, three by default) and the mode — one of the four, or Mix, a different mode each
round. The plan for every round is drawn at setup, so no player action ever needs the random
source (the game stays eligible for optimistic moves). A settled round holds the grid and shows
"Karim takes round 1 · 1–0" with NEXT ROUND (the host's online, anyone's on a shared phone); the
shell's turn card steps aside through a new interlude hook. The Players sheet shows
rounds won through the existing live-scores hook; the end screen ranks by rounds won, ties
shared. The chips stay on the board as the table's live override (Karim chose this over "sheet
only"): a switch restarts the current round in that mode, a fixed pick follows it for the rest of
the series, Mix keeps its plan. The series helper lives in the engine
(series.ts) so Connect Four can adopt it without new plumbing.
Why: a round of Tic-Tac-Toe is over in a minute; friends play four or five and count. That is the real game, and it also lifts the game to Karim's duration floor. Rounds and mode belong in the start sheet because that is where every game's host choices already live (the shells render it; games never mount settings UI).
Rejected: sheet only, chips removed (Karim wanted the live control); drawing Mix's next mode when the round starts (would put the random source on a player action); a "first to N" series (a fixed count is simpler to say, and an equal tally is a draw); showing the tally on the board (Karim: the Players sheet is the place for stats).
What would reverse it: a second series game with a different between-round ritual — then the round card moves from the game into the shell.
Decision: Tic-Tac-Toe and Connect Four no longer refuse a mid-match leave. A shared duel helper stops the match with not-enough-players, the same ways out as every other game, and a test asserts every real game lets a seated player leave. A guest's tap on a mode chip goes through, dimmed, and the rules refuse it with a toast.
Why: Karim applied the Trivia rule (2026-09-09, "leaving is a right; removing is not") to every game. "Host ends the game" trapped a guest in a room they wanted to leave.
Rejected: letting duels opt out of leaving (a player held in by the rules). A dead chip for guests (read as broken).
What would reverse it: a duel where a walk-out is used to dodge a loss, which would want the walk-out recorded as a loss, not a ban on leaving.
Decision: Tic-Tac-Toe offers four modes (Classic, Vanish, Slide, Avoid) as
chips above its board. The mode is part of the game state; a mode action starts a
fresh board in that mode, and rematch keeps it (carryOver). Online only the host may
switch (the reducer gates on the host id); on a shared phone anyone holding it can. A board with
marks on it asks "Start over in X? The board clears." before it is thrown away. The chip row is
the generic Chips primitive the shelf's vibe filter also uses.
Why: Karim wanted to switch "during the gameplay itself" with chips and a gentle tip, not a setting chosen before the round. Classic Tic-Tac-Toe is solved (two careful players always draw), so variety on the board is what keeps a two-minute game alive across many nights; Vanish and Slide cannot draw at all. Keeping the mode in the state means every seat and spectator draws the same chips with no extra plumbing in any shell.
Rejected: a host setting in the start sheet (the existing settings spec) — the shells already render those, but it would put the choice a screen away from the board and make two places for one fact; adjacent-only sliding (Three Men's Morris proper) — Karim described moving "to another position", and "any empty square" is the one-line rule; Ultimate (nine boards) now — it needs its own board layout and is the next step, not a chip; Wild, Notakto, numbers, 3D — different pieces or boards, skipped.
What would reverse it: a second game wanting modes with rules that must be chosen before seats are dealt (a player count that changes with the mode, say). Then modes become an engine-level setting and the chips read from it.
Decision: the viewport tag no longer says viewport-fit=cover. In a
browser tab the page's safe-area insets are zero, so the phone-app padding every screen carries
through the *-safe utilities (status bar above, home indicator below) collapses to its
plain offset on the web.
Why: Karim opened gamesforgroup.com in Chrome on his iPhone and found a
status-bar-sized gap above the header and a home-indicator-sized gap under the tab bar. The
browser's own bars already sit in those areas and the page starts below them. With
cover requested, Chrome on iOS still reports the device's insets (its web view is
laid out full screen; only its toolbars hide on scroll), so the App wore phone padding inside a
browser. Without cover the browser keeps content out of the unsafe areas itself and
reports zero, on Safari and Chrome alike.
Rejected: keeping cover and overriding each safe utility under
@media (display-mode: browser) (a list of class names kept in step by hand, and
uniwind's native compiler would have to swallow the media query); a platform check in every
screen (ten call sites for one fact about the web).
What would reverse it: an installed home-screen version (the manifest already
says standalone) that should paint the gradient under the status bar and home
indicator. Then cover comes back together with a rule that zeroes the insets only
under display-mode: browser.
Decision — gamesforgroup.com is the Expo web export. The App Home is the root,
and the pages that must be found — one per game (shipped and coming), contact, privacy, terms —
are ordinary app routes, static-rendered with their own title, description, canonical address,
link card and schema.org data (~/site/Seo); build-site.mjs writes the
sitemap and an llms.txt. apps/marketing (7,700 lines, its own stack,
twin components, a merge step) is deleted.
Why — Karim opened the live site after the redesign and found a different
home: the merged deploy gave the root to the content site, so the App Home — the rooms carousel,
sign-in, the account — never reached the web at all. The content site's reason to exist was
"search engines need static HTML, which an Expo export cannot give"; that stopped being true
(web.output: static renders every route to HTML and expo-router/head
renders per-page tags into it), and what remained was a second copy of the App Home — exactly the
duplicate screen the mode-shell rule forbids. AI assistants read the same static HTML (they run
no scripts), so one prerendered site serves both.
Rejected: keeping the content site for the game pages only (two renderers for one design, and a merge step that had already shipped two silent mistakes); a smart redirect that sends returning players from the static root into the app (keeps the duplicate and adds a flash). The cost accepted: every public page ships the app's script (about a megabyte gzipped) after its HTML paints — slower to become tappable for a stranger from search, not slower to appear; route-level chunking trims it. Would reverse it: search rankings measurably hurt by page weight, which the per-game pages' content and links outweigh today.
Decision — the moment a host's questions enter a match the pack is marked used: the lobby no longer offers it, so New game → the same game starts at the topic step, and Rematch does not replay the match. On a question game Rematch asks one thing — "new questions on ‹topic›" or "pick a new topic" — and sends the host back through set-up with that intent; the sheet opens already generating on the same topic (and the difficulty it was asked for), or at the topic step. Karim, 2026-09-11, from TestFlight: "we have already played the questions… if Rematch repeats the same question there's no point." The row is kept, not deleted, so the topic can be asked for again. Rejected: regenerating silently inside the old Rematch (a host may want a different topic, and generation should be a visible step with its preview); deleting the pack row at start (loses the topic). Same behaviour on one device: the session remembers the topic and the set-up screen takes the intent.
Decision — a room is dead once nobody in it has beaten presence for thirty
minutes, whatever its status; the sweep runs every fifteen minutes. Karim caught the old rule
("ended, or created over a day ago") deleting a room people were still playing in: "if a room is
still active and players are playing, it must not be cleaned up". Age is not activity; the
heartbeats the roster already sends are. An ended room is a winner screen people may
still be sitting on, so status plays no part.
Decision — a room is open while its host still holds a seat in it, and that one question answers three: may people knock (yes, mid-game too — the host lets them in from the in-game Players sheet and they watch until the next start), does the host's card still show, and may the host open another room (no — one open room per host, the host screen offers Back to it or Close it). Rejected: lobby-only knocking, which made "the room you left is still going" a card with nothing to press; a status check in each of the three places, which is how they drift apart.
Decision — the App Home's "Jump back in" row shows only rooms that are open: one full-width card, or a swipe row with dots for two or more; hosted rooms wear the crown. The phone remembers rooms by id (codes are reused once a room is reaped) and drops any id the server stops answering for, so a card never points at a stranger's room and never dead-ends. Rejected: a same-device "Continue" card here (the couch's own set-up screen already offers it), a rail that shows when empty, a Profile history of rooms.
Decision — rooms stay temporary. The daily cleaner keeps reaping them, codes keep being reused, and nothing about a room outlives it (Karim, 2026-09-10: "a temporary space we are creating, not a permanent room"). A host has ONE open room at a time; opening another meets "your room is still open" with Back to it or Close it and start fresh. The App Home carries a carousel of the rooms this device can still walk back into — hosted ones wear the crown, joined ones do not — and an ended room never appears anywhere, not even on a "your rooms" list. Because codes are reused, the device remembers rooms by id, never by code.
Rejected: long-living rooms / "Friday crew" groups with remembered members (the recurrence answer I proposed) — a reused code pointing at a stranger's room and a stale list are exactly the problems it would create; the host cap of five open rooms (nobody needs five); a Profile "Your rooms" history (there is nothing to list once a room ends). Would reverse it: a decision to sell recurring rooms as a feature.
Decision — the start sheet skips its options step when there is nothing to ask (no seat picker, no game options — the same-device sheet, whose settings are all clocks the couch has no use for): "Random questions" starts the game, and the ready card and preview end in START GAME rather than a Continue that led to an empty screen (Karim, 2026-09-10). Adding a question from the preview is ONE question and a Done button — Previous / Next beside Back / Use these was four buttons and a counter for a screen that needed one. Random questions is a PICK, not a jump: the door lights up and the footer says what comes next (START GAME, or Continue to the options) — auto-starting on the tap surprised him. On the couch board, "Nobody got it" is dressed like the name tiles, not like the answer button, and an award can be undone (Undo beside "Leanne takes the point") — a slip of the thumb is not a scoring decision. The couch board loses its "Read it out" label and its scores list; the header carries the same Players pill the room has, with standings behind it. Preview option tiles got room to breathe.
Decision — a per-player setting (tickets per player) gets a tab of its own beside "Options" in the start sheet: Tambola seats thirty, and thirty stepper rows buried every other option under them. The split is generic — any game with a per-player field gets the same two tabs, and a game without one sees no tabs at all. Rejected: a collapsible tickets list (still thirty rows to scroll past to reach call speed).
The header's sound switch is the only switch. The caller sheet's own "Read numbers aloud" toggle is gone: sound on means the numbers are read out, sound off means silence. The per-device opt-in existed for the browser's speech-gesture gate; a tap on the caller primes speech now, and every player taps within seconds of the first call. The sheet keeps only what the host controls (pause, call speed) and the called-numbers board, whose cells are round.
No box on a ticket has a fill of its own. The shaded blanks and the faint yellow wash on a called number were too close to tell apart, so blanks are plain boxes (as on a printed ticket) and the ONLY highlight is a called number, washed in the player's own colour at a fifth strength — a hint that says "you may daub this", in the colour of the daub it will get. Rejected: solid yellow (Karim: shouted, and fought the white numerals in the dark).
The phone play header is two rows. The game's name takes the first, big; sound · appearance · players · End game take the second in the lobby's order. Tablets and desktops keep the single row. Chosen from four options (one ⋯ menu; icon + short name; name moved into the board) for the reasons that mattered most: every name fits for good, and mute stays one tap away.
Tickets come from a strip. Real Tambola sells tickets in strips of six, and a strip holds every number from 1 to 90 exactly once — so a player with two, three or six tickets never sees the same number twice (Karim, 2026-09-09: 34 sat on tickets 1 and 5, 36 on three of six). Each player is dealt one strip, shuffled, and takes the first N. Tickets were drawn one by one before, each valid on its own and blind to the others. Rejected: drawing N tickets and rejecting repeats (fails outright at six, where the only answer IS the strip).
A ticket also has to LOOK like a ticket. No rule governs how the fifteen numbers spread — the sources agree: five per row, one to three per column, ascending, a strip covers 1–90 once — but a printed ticket never looks lopsided, and ours did (a row filled edge to edge; three full columns side by side). Measured over 2,400 tickets: one row in ten ran all five numbers in a straight line. Two looks-only limits fix it without touching the rules: at most two full columns per ticket, and a row never runs more than three numbers in a line (the layout retries a few shuffles and keeps the least lopsided). Now: no five-in-a-line rows, no three-full-column tickets, four-in-a-line rows down from 21% to 4%.
The caller tells everyone when it is paused. The host's pause used to be visible only to the host; every other screen just stopped getting numbers. The floating caller now shows a pause mark in its well while calling is paused, on every screen. Its sheet opens with the last five numbers in the order they came, the newest dressed as the caller's face, and the 1–90 board marks a called number as a solid yellow face — the faint tint was too quiet to scan across ninety cells.
A claim is announced to the room, as a toast. Only the claimant's own tile
used to change; everyone else found out by scrolling up. Now a claim holds the caller for five
seconds (the next number waits; a longer wait already running is left alone), every screen gets
the app's own toast — "Lappy claimed Top Row" — and, with sound on, hears "Top Row claimed"
(the voice skips the name). Full house too: the winner screen waits out the same five-second
hold so the room sees who took it before the standings. The toast is the shell's: a game declares
rules.announce(prev, next) and every mode shows it the same way, because a game
never mounts chrome. The claim rides in the match state (lastClaim), so a screen
that arrives late says nothing. Rejected: a card the overlay drew itself (it was a second
toast, and it stuck). A prize nobody has yet reads "Not claimed", not "Keep daubing". The caller
sheet wears a "Paused" pill in its corner while the host has calling stopped.
Prizes go in order. Early Five is always the first claim of the match — a
player whose first row fills early claims Early Five on those five numbers first, then the row
— and Full House opens only once Early Five and all three rows are gone, whoever took them
(Karim, 2026-09-09: prizes claimed out of order is not Tambola). One selector,
claimOpen, answers both the tile (which says "Early Five first" / "Every line first"
instead of offering the claim) and the reducer, which refuses with the same words.
Rejected: independent prizes (the common app shortcut).
Running out of numbers is not an ending. When the 90th number is called the caller simply stops; every ticket is complete, so the prizes go in order and Full House ends the match — or the host does. The match used to end itself one interval after the 90th call and crown whoever led on points, which surprised a room that had not seen a Full House (Karim, 2026-09-10). The caller sheet says "All 90 called — claim Full House".
Wide screens. The five prize tiles sit in one strip from tablet width up (the full-house tile used to overhang the pairs), and the floating caller starts in the header's empty middle instead of the bottom-right corner, which on a desktop is far from everything.
Decision — the host's step button is the game's floating Overlay, pinned to the bottom of the screen (thumb reach, and it no longer sits between the question and the options). Its colour says the step — yellow shows the question, cyan the options, green the answer, purple moves on — because four labels that differ by one word are not a signal a host mid-sentence reads. When the question is up but the options are not, everyone (the host too) sees four blank cells A–D, the way a quiz show reveals them; the "waiting on the host" card stays only for the moment before the question exists. Rejected: keeping the button in the board (it pushed the question down on every phone) and the "options are next" card (Karim: just show the blanks).
Time up closes the answers, and that is all. In host mode the clock running out never reveals: answers close, the room reads "Time's up! Waiting on Karim for the answer", and the host shows it when they are ready — the same as when everyone is in early. Auto mode keeps revealing on the clock.
Leaving is a right; removing is not. A guest may always leave, even one of
the last two in host mode (Karim, 2026-09-09). If that takes the match below what it needs, it
stops short rather than crowning whoever stayed: the result carries
ended: 'not-enough-players', and everyone sees "Not enough players to continue"
with the ways out — the host gets Back to room and End room, a guest gets Leave room and can
wait for the host. The host's Remove on one of the last two stays greyed, because that would
end the game for everyone else. Two rules hooks now, not one: canPlayerLeave
(may they walk) and canRemovePlayer (may the host take them out).
Players is a sheet anyone can open. The 👥 pill is in every play header now,
not the host's alone; it lists who is in, with rank and score when the game keeps one
(rules.liveScores, optional), and watchers under them. The host's copy carries a
Remove per seated player that opens the lobby's Remove / Keep them in sheet (Ban was removed
2026-09-14). Remove is
greyed for a player the game cannot lose (rules.canPlayerLeave — in host mode the
last two answering), with one line saying why, instead of a sheet that opened and closed itself.
Decision — online Trivia can be run by the host (runBy: 'host'):
they are seated but not scored, and one button walks the room through each question — show
the question, show the options, show the answer, next. Everyone else answers on their own
screen. The answer clock starts when the options appear and stops early once everyone is in;
the answer then waits for the host, never a timer. Speed bonus stays (it is measured against
the clock that was running when a player locked in), so the scoring is the same in both modes.
Needs three people — a host running a quiz for one is not a game, so setup refuses rather than
quietly turning into auto. Rejected: flat scoring in host mode (Karim: a clock makes
the bonus fair); auto-revealing when everyone is in (the host wanted the reveal). Same-device's
one-phone shape stays its own thing (hostRun): no clocks, no per-player screens.
Clocks are the host's, in fives. Answer time (10–60s) and question interval
(5–30s) are both settings and both step by five seconds; a one-second stepper made every
change fifteen taps. A wrong answer can cost 50 when the host switches that on — a pass is
always free. None of these are asked on one shared phone, which is what modes on a
settings field is for.
Waiting copy says who. Before the host shows the next thing, everyone's screen says "Waiting on Karim" and what is coming — the host's own screen skips the card, since they are the one being waited on.
The room's size gates the choice, in the sheet. With two people the Host
chip is greyed with "Host mode needs at least 3 players." under it, and the field sends Auto;
the reducer's own refusal stays as a backstop, never as the message. A select option carries
minPlayers + hint for this, so any game can gate a choice the same way.
Rejected: hiding the chip (then nobody learns the mode exists).
Random questions are a first-class door. The topic step has a third way in beside typing a topic and writing your own — two door cards under an "or" rule, "Write my own" and "Random questions", shaped unlike the topic chips so they never read as one more topic. Random is the built-in bank, no preview, straight to the options (Karim: making questions will cost credits and writing ten by hand is not a real option for most hosts). It clears any pack the room had.
Decision — the preview used to be two lists with ✕ and + buttons: drop one from
the game, swap a spare in. Karim: "not the right UI — let them rearrange, and if they don't want
one they move it to the spares." It is now ONE list with a Spares divider that is
itself a row: hold a question, drag it above the line and it plays (in that order), below and it
sits out. Where the divider lands is how many play — twelve if the host drags two spares up,
eight if two go down. The pack remembers the split (playCount, server and local), the
game receives only the questions above the line, and a Trivia match plays that whole length —
the rule of ten went with the offline draw, which nothing used any more. Rejected: a
second gesture (swipe to remove) beside the drag — one motion is the whole story.
The sheet's last screen is the options and START. After the wait the ready card says what is ready (count, spares, topic) with "Preview the questions" inside it, and the footer continues in cyan; yellow belongs to START GAME alone, on the options screen. Going back from the ready card warns that the made questions are lost and new ones cost credits — the charging is not built yet, but the words are true the day it is.
Also from the round: the play header runs name · sound · appearance · players · End game, the lobby's order. The winner screen says "Name wins!" and "It's a draw!" — "takes it" and "dead heat" were the wrong register. Rematch is party yellow unless the winner's own colour is yellow or gold, when the screen is already yellow twice and the button turns cyan. The screen's root no longer clips, which was cutting the buttons' rim shadows. And "Gap between questions" is "Question interval".
Decision — the first cut of "the host makes the questions" put the topic box, the write-my-own button, the game options and a sleeping START on one sheet. Karim: "too much information, very confusing, I want to be very minimal." The sheet is now four screens that each ask one thing: topic (a box, ready-made topics as chips, "write my own" underneath, and Continue), the wait, ready ("10 questions about Animals are ready" — what the game plays, never the spares — the game's options, Preview questions / START), and preview. All four live inside ONE sheet whose content changes: a first cut mounted a fresh sheet per step, which read as the sheet closing and reopening, and on the phone the second one dismissed itself. Rejected: keeping everything on one scrolling sheet with sections — every section was competing for the first glance.
Answers are hidden in the preview until one toggle shows them. The host usually plays too; a preview that printed every answer spoiled their own game. One eye at the top of the list, password-style. The old "tap a question for its wrong answers" stays for the host who wants to check a generated answer.
Writing your own is one question at a time. Ten empty forms on one screen
was a wall. Now: one question, four answers, Previous and Next, a count. And there is no rule of
ten: the game's floor is three, the match plays the pack's length up to ten, and "Use these" is
on from the floor up — five questions is a quiz if five is what the host wanted. The rules
changed to match (a short pack plays short instead of falling back to the bank), with tests.
Chips come from the game's own packSpec.topics, so another question game can offer
its own.
The host makes the questions before a question game starts (online). The start sheet used to offer two buttons — make questions about a topic, write my own — beside a START that worked without either, because the built-in bank played by default. Karim's ruling: "control over the questions beats a faster start; without control it doesn't matter how fast." So online the sheet leads with the topic box, the half-minute wait, then the preview to read, prune and reorder; START sleeps until a pack exists, and says why. "Write my own" sits beneath the topic box for the host who would rather type. Offline there is nothing to decide — the game's own questions play and the section does not appear. Rejected: stating the built-in pack as one line with a Change control — faster, but it keeps the bank as the default and Karim wants the default to be the host's own. What would reverse it: evidence that hosts abandon the sheet at the topic box. Consequence to watch: a same-device host with internet but no account meets the sign-in ask at "Make the questions"; "Write my own" is the no-account way through.
Removing a player is one sheet with two meanings. "Remove" keeps the code working for them (a friend who stepped out); "Ban" adds them to the room's ban list, the same two keys a denied knock records (a pest). Both are candy buttons with their meaning printed under them; "Keep them in" is the quiet way out, so the mis-tap that opened the sheet costs nothing. Rejected: remove now and offer Ban in a toast (a timed decision), and a "don't let them back" switch inside a confirm (a form where a choice belongs).
The QR card is the whole invitation. Brand the way the header wears it, the pattern with the code in the middle, "Karimulla invited you to", the room's name, and one small line — "Scan to join · Tue 8 Sep" — so a picture found days later reads as old. Chosen from four rendered options; the yellow "or type the code" bar was offered and declined as one thing too many. Room names cap at 30 characters so the card, and the lobby card, never cut one to "…".
Decision — every board sat in a phone-width island (about 440px) no matter
how big the window was, because the play shell's column and each board's own cap were both
written for a phone. On a laptop that left six Tambola tickets in a single narrow column with the
prize tiles scrolled off the top, and a Ludo board the size of a coaster in the middle of a
1440px window. Karim's ruling: use the space. The play screens now ask the shell for its
wide column (680px on a tablet, 980px on a desktop), and a board takes its own
ceiling from one shared rule, useBoardMax('square' | 'wide') — a square board
(grids, dice) stops at 600px so the whole thing plus the frame stays on a 950px-tall screen;
a flowing board (tickets, questions, standings) reads at up to 920px. Rejected: a
literal class per breakpoint in each of eleven boards — eleven copies of one rule, and uniwind
cannot see a class name built at runtime anyway. What would reverse it: a board that
needs the shell itself to change shape (side panels), at which point the shell grows a real
desktop layout the way the lobby did.
A Tambola ticket is drawn the way a printed one is. The old ticket was twenty-seven loose rounded squares with gaps between them, and the twelve blanks were darker squares — which read as missing tiles, not as a ticket (Karim: "the empty boxes look like the missing cells"). A ticket is now one card on the outline and rim, a header strip in the player's colour, and a continuous 3×9 grid whose blanks are shaded boxes on the card. A called number gets a faint yellow wash; a daub is a round blob in the player's colour over the number, the mark a real dauber leaves. On a wide screen two tickets sit side by side, so six are three rows. Rejected: hatching the blanks with an SVG pattern — twenty-seven patterns per ticket times six tickets for a detail a solid shade already carries.
Decision — the couch play header used to carry a "Dev" pill (dev builds only)
beside End game and the two switches. On a 402px phone that pill took the room the
game's name needed, so every development screenshot read SNAKES & L… while the
shipped app would have read the whole name. A reviewer cannot tell those apart, and the
screenshots are how this project is reviewed. The menu now opens from a long press on the name in
dev builds, which changes nothing visible. Rejected: a smaller icon-only pill — it still
clipped the two longest names; and hiding the pill on narrow widths — a layout rule for a thing
that should not be in the layout at all. What would reverse it: a second dev control
needing a home, at which point a dev-only floating tag outside the header is the answer, never a
seat in the product's row.
Also, from the same pass: the seat picker no longer shows the HOST badge. It
asks one question — who plays — and the roster above already wears the crown; in a half-width
chip the badge plus the seat number squeezed the host's own name to K…. Cut what the
screen is understood without.
Decision — two small calls made while photographing every online screen for review.
A nameless room is "Maya's room", never "Your room." Naming a room is optional, and clearing the name in the room editor is a real thing a host does. The lobby used to fall back to the words Your room — true on the host's screen and a small lie on everybody else's, since the room is not theirs. Worse, the same words travelled: the shareable QR card carries the room's name, so a picture sent to a friend arrived titled "Your room". The fallback is now the host's own name, which is exactly what the create form promises in its placeholder and exactly what the door said on the way in — one sentence for the room, wherever you read it. Rejected: leaving it host-only and giving guests different words, which is two copies of one sentence and the drift this project keeps paying for. What would reverse it: rooms getting an owner who is not a player.
The team-win crest wears the same candy face as a player's avatar. When a team wins, the crest stands where the winner's face would be. It was the last thing on that screen still dressed in the old neon — a faint tint inside a hairline with a blurred glow behind it — which meant the one screen everybody looks at ended a match in two different visual languages, and on the light ground it was a pale star on an almost-white square. It is now a solid accent face with the thick dark outline and the hard rim, sized like the avatar it stands in for. Rejected: keeping the glow "because a team is not a person" — the shape already says that; the glow only said "built earlier".
Decision — the redesign finished its pass over everything the App still drew in the old neon: the question sheet, the play frame around a running game, the end of a game, the eleven boards, and the website. Three calls inside that are worth writing down, because they removed something rather than restyling it.
The room code is gone from the play header. Nobody can join a room that is already playing — a non-member who opens the link gets "Game in progress" — so mid-match the code is a fact with nothing to do, sitting in glowing yellow above the board. Sharing a room is the lobby's whole job, where it has a QR card, a copy button and a share sheet. Rejected: keeping it as a small chip (it still costs the width the game's own name needs on a phone). Reversed by: ever letting somebody join a match in progress.
The pack preview shows every answer without a tap. Reading the answers IS the safeguard against a model getting one wrong in front of thirty people, and the old screen hid each one behind its own tap — fifteen taps to perform a check that a host who skips it has not performed at all. The answer now sits under every question; tapping adds the three wrong options, which are the cheap part. Rejected: a "reveal all" button (one more thing to not press).
The Shopfront's old token names became aliases instead of being deleted.
--color-neon-pink now resolves to the party pink, --font-arcade to
Lilita One. That let the home page be rebuilt properly while every other page moved to the new
palette and faces for free, and can be reshaped one at a time. Rejected: a flag-day rename across
every marketing file (a day of mechanical churn, and a broken site for the length of it).
Reversed by: the last page being reshaped, at which point the aliases are dead weight and go.
Also settled in passing: yellow is the primary action, so a sheet whose footer holds
START GAME may not put a second yellow button in its body; a board's greys come from the theme's
own ink (ui/useInk) rather than white, or the board vanishes on the light ground;
and a new --color-hole token carries a recess, because on the light appearance every
surface we had was white and depth could only be drawn with a line.
Decision — the online lobby now draws itself with the same pieces the
same-device set-up screen uses: one layout, one game picker, one Start bar, one set-up sheet
(moved into ~/ui), and one tested set of seat rules
(src/modes/setupRules.ts + useSeatPicks). What stays the lobby's own is
what a room genuinely has and a phone does not: the room card (the code on cream tiles, the
pencil that edits the room, copy link, share, QR), the door queue, and the guest's half of the
screen. Nothing is picked when the lobby opens, the same as on the couch. From the mock we cut
the match modifiers, the reactions bar, the chat line and the ready check.
And after Karim's first look (7 Sep): everyone sees every game — a guest gets
the room's pick as a big card and the rest as a grid under it, because the table argues about
what to play and then tells the host. On a phone the people and the games sit behind two tabs
(Room · N / Pick a game) so a room of thirty never buries the grid; the Room
tab wears a live dot while somebody is knocking, since a question the host is not looking at may
as well not have been asked. The pencil edits the room's NAME and its SEAT COUNT — a host who
opened a room for eight can make space for twelve without opening a new one; the server clamps
the number to the shared bounds and refuses to go below the people already in
(rooms.updateDetails, tested). The lobby's pill is one word, Live, with the
sound and appearance switches beside it, and those two now ride every play screen as
ui/ScreenControls. At the door, Keep out sits on the left and Let in
on the right — the answer given most often belongs where the thumb lands — and both are flat
(a new look="flat" on the button), because one raised and one flat side by side
reads as a mistake.
Why — the two screens ask the same host the same two questions (who is here, what are we playing), and the last time a screen was rebuilt from the requirements instead of from its sibling it shipped three bugs the sibling had already fixed. The mock's extras each fail the "why does this exist" test here: playstyle was already chosen on the home page, a round clock and an accessible mode are per-game options that already live in the set-up sheet, ready checks are what knock-to-join replaced, and a chat that exists only in the lobby is a chat nobody will use — friends in a room are already talking.
And after his second look (7 Sep): the room's QR became a CARD worth
sending — the brand line, the pattern with the room's four characters printed in the middle of
it, and the room's name underneath, drawn once as SVG so the thing on screen and the file that
gets saved are the same picture (ui/qrCardSvg). The browser downloads it as a PNG it
rasterises itself; a phone hands it to the share sheet, where "Save Image" is how it reaches the
camera roll — so no photo-library permission and no extra native module. The card's buttons are
Copy code, Copy link and the QR; sharing moved inside the QR sheet, where the picture and the
link travel together. Two reversals of earlier calls, both his: a DIALOG now carries a close
button in its corner (on a desktop the only way out was clicking the dark area, which nobody
reads as a control — sheets keep the grabber), and the dialog's backdrop blurs. Every button's
rim came down from 6px to 4px: a raised button beside a flat one read as a mistake, and the pop
belongs to CARDS. The people/games switch is one segmented track rather than two buttons, and
the words are the app's own — Players, Games.
And his third look (7 Sep): the phone saves the picture too. That needs three
native modules (react-native-view-shot to capture the card,
expo-media-library to put it in the camera roll, expo-sharing for
Android's sheet) and a rebuilt dev client — and Karim's ruling on that is the general one:
never drop a feature because of effort; only because it is technically impossible. The
three are required inside the functions, the way the audio engine is, so a dev
client built before them keeps working with only Download and Share unavailable. Components came OFF the shelf
by the promotion path (src/fx → src/ui + a /catalog demo + the shelf
demo deleted): ToastStack, whose stacked swipe-away cards are now the app's real
toasts, and GooeyTabs for the lobby's Players / Games switch — because two buttons
read as two buttons and a switch should read as one control that moves. The gooey one lasted a
day: its Skia metaball needed a second, web-only implementation, and on a phone the goo read as
a smear rather than a switch, so ui/Tabs replaced it — one sliding pill, one file,
every platform (Karim, 8 Sep). Editing the room clears the card down to the two
fields being changed — the code and the ways to copy it are not part of that question. And the
card itself shrank to a name and the code: on a phone it sat at the top of the screen and every
pixel it took was a game the host could not see, so the ways to hand the room over (copy the
link, save the picture, share it) all live one tap away behind a single Share pill. Four games
now fit on the screen where two did.
The code stopped being tiles (Karim picked it off a page of 36 drafts, 8 Sep). Cream tiles with a dark rim are what the JOIN screen uses for typing a code into — putting the same tiles next to an equally cream QR button made five squares where there were four characters and a control. So on this card the code is type: big, in the yellow the tokens reserve for codes, and the whole of it is the copy button with a small copy mark beside it. One pill, labelled Share, opens the sheet that holds the QR, the link and the picture. The tiles keep their job on the join door, where a code really is being entered.
Rejected — a separate lobby picker (that is how the two screens drift); hiding the games from guests (tried first, and wrong: seeing the shelf is how the room decides together — guests can look and open a how-to, only the host's tap counts); a default game (a seat cap nobody chose); the door queue as its own strip (who is here and who wants in is one question, so it is one card); a confirm on turning someone away (friends arrive several at a time; the row says it is for good, once).
What would reverse it — a mode whose host answers a genuinely different question. Then the shared pieces take a prop, or that mode gets its own screen and says so.
Decision — the Profile page gets "Edit name": the name a host plays as is
saved on their account (a users.setName mutation), so it prefills Create room on
every device they sign in on; the phone's own memory is updated in the same tap so joins and
same-device games agree. The provider's full name is only the starting value. The Profile page
also gets a Sound card (on/off). On the web only, the home header carries the sound switch next
to the appearance switch, because browsers refuse to start audio until the person taps something
— the switch is that tap, and it unlocks the engine as it flips. The web therefore starts
silent and the switch is how sound goes on; phones start with sound on and keep the
switch on Profile. Whichever the player chooses is remembered and beats the starting state on
every later visit; on the web the engine wakes on their first tap anywhere, so a returning
player with sound on hears it without touching the switch again.
Why — Karim's account came through as "Karimulla Mohammed" and every room he hosted took the full name with no way to change it. Saving the short name on the account rather than the phone is what makes "change it once" true. The web sound button answers the browser's autoplay rule instead of fighting it (principle 11), and starting silent there makes the switch tell the truth: a page that shows "sound on" while the browser is refusing to make a sound is lying to the player (Karim, 2026-09-07).
Rejected — saving the name only on the device (it would not follow the host to a laptop); editing the name inline on Create room every time (that is the problem, not the fix); a "tap to enable sound" banner on the web (a nag; the switch in the header is quieter and always there); one starting state for every platform (the platforms genuinely differ — a phone has no autoplay rule to answer); a sheet for the name editor (built first — on the phone the keyboard covered the sheet and the field was nowhere; the editor now swaps in place inside the card, Karim's call).
What would reverse it — per-room nicknames becoming a feature; then the account name is the default and the lobby owns the override.
Decision — every "before you are in" moment (finding the room, the name field, knocking, and the six dead ends) renders on one shell: the stage, the Back pill and the Online room / Room live pill up top, a pink door card in the middle, one button at the foot. The room code is drawn as four cream tiles — the same tiles the home's join door uses for typing one — wherever it appears, so a code looks the same being entered and being read back. Dead ends open with an emoji tile and a display-face headline; the copy is one line each ("Nothing at this code right now. Ask the host for a fresh one.").
Why — eleven states that each drew their own layout would drift in eleven directions; one shell with slots cannot. The tiles carry the code's identity across the whole join journey. The old yellow glowing chip and the coloured section eyebrows ("Hmm", "No room here") were the neon system's; the party system says it with a picture and a headline.
Rejected — a second code style for display (the glowing chip): two spellings of the same thing. Keeping the "Room live" dot as a bare header line: it is now the header pill itself, green dot and all, so it reads as a state.
What would reverse it — a join state that needs a form longer than a name (it would outgrow the card); then the card becomes a page and this shell a header.
Decision — the online entry screen wears the party language with the same
shape it had: signed out, a pitch and one Google button; signed in, your name, an optional room
name, the room size, and Open the room. Both states open on "HOST A GAME NIGHT". The
why-sign-in card says the true thing plainly: one sign-in so the room has a host, friends never
sign in. The form's pieces are shared: TextField (label, optional tag, the well)
and the Stepper the game options already used.
Why — the Stitch mock puts a Pass & Play / TV / Online play-style picker inside Create room, a starting game, player-cap chips, family-safe and voice toggles, a sandbox, save-as-default and a live PIN preview. The mode is chosen on Home before anyone gets here (a second picker duplicates the mode sheet); the game and its options live in the lobby, where the whole table can see them; the code exists only once the room does. Each of those would be a feature with nothing behind it — "ask why it exists" (CLAUDE.md).
Rejected — player-cap chips (2/4/8/16/32+): the product's room size is any number 2–30, and a stepper says that; chips would invent tiers.
Review round 2, same day — "room", not "night" (rooms open at lunch too);
the why-sign-in paragraph became three emoji tiles and one line, "Sign in to host a room";
Sign in with Apple joins Google everywhere a sign-in is offered (the Profile
page, the same-device questions sheet, here) because the App Store requires it whenever a
third-party sign-in exists — one SignInButton with the provider's real logo, and the
Apple provider registered in convex/auth.ts (it needs
AUTH_APPLE_ID and AUTH_APPLE_SECRET on the deployment before it works);
the header's account chip is gone from this screen (sign-out lives on Profile) and the Online
room pill sits there in both states; the form runs Room name → Your name → Room size, the name
is prefilled from the profile, and a blank room name becomes "<Host>'s room" so no room is
ever nameless. Round 3: the two sign-in buttons moved inside the card (the dev one stays out),
and the phone's two-up game cards wear the shorter rim with a wider gutter so stacked cards no
longer touch. Switching Apple on is a runbook: apple-sign-in.html.
What would reverse it — a real "TV mode" shipping; then a play-style choice belongs somewhere, and Home's mode sheet is the first candidate, not this screen.
Decision — the pass-and-play screen no longer builds its own board frame.
It mounts the same PlayShell the online room and the LAN host mount (turn
banner, spectator chip, overlay slot, lazy board), in a pass-and-play flavour: the
banner names the phone-holder's turn in the third person with "pass the device →" (none for a
game that keeps the phone in one pair of hands), and the blip fires when the phone changes
hands rather than on the my-turn edge. Around it: the game's name in the display face, End game
as a confirm dialog, the round sound switch. The hand-off gate became a party door card in the
player's colour, the name on a colour pill so a yellow player is readable on the light ground.
Why — the mode shells are one product (CLAUDE.md). The local frame was a second copy of the shared one with its own banner hint, its own blip, its own loading state and no overlay slot; redesigning it in place would have meant restyling the same frame twice and keeping two in step by hand — the exact failure the 17 Aug post-mortem records.
Kept from the mock — keep-awake: the phone goes round the table and lies face-down between turns, so the match screen holds the screen awake for as long as it is mounted (Karim, 6 Sep: "keep awake is in").
Rejected — the mock's other extras (skip player, turn duration, voice read-aloud): features, not styling; none exists in the shell and none was asked for. "Next up" and standings on the hand-off: the shell knows only the current seat, never a game's turn order or mid-match scores — showing a guess is worse than nothing. Inline End-game confirm (two 9px buttons in the header): the shared confirm dialog is the app's one way to ask "are you sure?".
What would reverse it — a same-device game that needs the frame to differ in something a player can see, not just in copy; then the flavour prop becomes a second component, and that would be the signal the modes are drifting apart.
Decision — "Who's playing?" is the first in-play screen in the party language. Its roster sits in a yellow door card because the home's couch door is yellow: the colour a player tapped to get here is the colour of where they land. The game grid reuses the home cartridge; the start sheet reuses the home mode sheet. Every shared piece the screen opens — player avatar and chip, the seat picker, the options fields, the questions set-up — was restyled in the same pass, and the only per-screen code is the roster card, the picker, the pinned start bar and the two sheets. The shelf's filter and search state moved into the shelf itself; the two tab routes that mount it no longer carry it.
Why — a screen that opens a sheet in the old neon look is not redesigned, it is half redesigned, and the half is what a reviewer sees first. Restyling shared parts when the first screen needs them keeps every later screen's work smaller (the lobby gets the seat picker and the options fields for free). Carrying the door colour answers "where am I?" without an eyebrow.
Rejected — a Stitch mock for this screen (there is none; making one would have cost a round-trip for a screen the system already dictates); a per-screen chip variant (the old chip had a "compact" look nothing else used — the single look replaced it); routes owning the shelf's filter state (nothing outside the shelf reads it, so the plumbing was pure duplication).
Review round 2, same day — Karim's notes and what they became: the desktop was a stretched phone, so desktops now run two columns (roster and Start on the left, the grid on the right, each scrolling alone — Start never leaves the screen, thirty names never push the games away); a card is picked by tapping anywhere on it, not only the art (a picker tile is one pressable edge to edge, the "?" its sibling — web forbids a button inside a button); every card has a "?" dot and a long press that open the game's how-to sheet, the SAME sheet the online lobby and LAN host already had (their two private copies are gone) — the "?" shows on phones too, because a long press is invisible; the eleven how-to cards were eleven copies of one step list in hard white, unreadable on the light sheet, so they are now words only on a shared `HowToSteps`; the back arrow is drawn, because Lilita One has no arrow glyph and each platform fell back to a different font; and sheets cap at 560px centred on wide windows instead of spanning the desktop.
Review round 3, same day — "it should feel native wherever you see it". Three calls. (1) The desktop's Start lives INSIDE the roster door card, hint above it, like the home's couch door carries its own button — the floating bar with its divider and strip was a phone part pasted onto the desktop. Rejected: keeping it pinned at the column's foot (a lone button far from the roster), and a sticky bar (no such thing in native layout). (2) The app dialog has two presentations behind ONE component: a bottom sheet on phones, a centred candy dialog (fade + scale, scrim, Escape) on tablets and desktops — the split iOS itself makes with page sheets and form sheets. Rejected: a side drawer (covers the very grid the "?" came from; no native analogue for a confirm or a set-up form), and a per-screen choice (every sheet — the set-up, sign-in, mode, confirm — gets the right presentation for free this way). (3) The how-to is a poster moment, not a text block: the art as a hero fading into the panel with the vibe badge, the name in the display face over the tagline, time and player chips, the steps in a soft box, and a PICK button so reading the rules can end in choosing the game.
Review round 4, same day. The close icon is gone from every dialog: the backdrop, Escape, the drag handle and a footer's Close were already four ways out, and a fifth read as clutter. The phone sheet's poster now runs edge to edge from the sheet's top corners with the grabber floating over it (gorhom's handle strip is dropped when a hero is present) — the same feel as the desktop dialog. And the "flat purple" Karim saw was two real gaps, not the design: the stage's five pools were ellipses in percent of width and height, which flattened into faint bands on a wide window — they are now circles sized to the stage's farthest corner, exactly the mock's CSS; and the dialog panel was a flat token where every panel in the mock is a top-to-bottom card gradient — it wears that gradient now, and the poster fades OUT (an alpha mask) rather than into a fixed colour, so no band shows where the gradient has already darkened.
What would reverse it — a mode whose door is not one colour (a purple online-or-couch door, say); then the landing colour would have to come from the mode, not the door.
Decision — on a phone the home splits into three tabs: Home (the pitch,
the three doors, the host banner), Games (the whole shelf, two compact cards per row) and
Profile (sign-in or the signed-in name, the appearance switch, the small print). The three
live in a route group under Expo Router's tab navigator with our own bar as its
tabBar, so a tap switches in place the way the platform does (the first cut
replaced routes on the stack, and every switch slid in like a pushed page — Karim caught it
on the simulator); tablets and desktops keep the one long page and never show the bar. The appearance control is a single button
that flips light ↔ dark; the first visit follows the phone, and after a tap the device
remembers the choice. On the light ground the hero's second line, the wordmark and the active
tab wear violet instead of yellow.
Why — Karim's review of the rebuilt home: the shelf was a long scroll away on a phone, the three-option sheet felt broken, the round sign-in button looked odd in the header, and yellow on lavender had no contrast. The tab bar reverses his 31 Aug "no bottom navigation" ruling on purpose — that ruling was made against the hired designer's four tabs on a one-page home; with the page this long, the tabs earn their place.
Rejected — tabs as in-page state (no back button, no links);
stack routes wearing a bar (built first, wrong feel — a tab is not a page);
the stock tab bar (cannot wear the party outline, and the desktop must not show
one — a custom tabBar keeps the navigator and swaps only the chrome);
keeping "match my phone" as a third option (the default already does that until
the first tap).
What would reverse it — a fourth tab. The bar is three items because three is what a thumb can tell apart; a fourth would mean the home is carrying too much.
Decision — the redesign's reference is the Stitch mock set in
stitch_games4group_ui_concepts_redesign/, and for the App Home specifically its
Casual Party Arcade page, matched section for section: the five-pool purple stage
with party emoji, the two-tone Lilita One headline, the three mode doors (host / couch / join
with code tiles), how-it-works, the browse-by-vibe shelf of candy-bordered cards, a host
banner, a footer. Copy is ours where the mock's was false ("150+ games", "no accounts" — the
host signs in). The old tokens, fonts and the six home primitives they fed were deleted, not
aliased: Karim is the only user, so there is nothing to stay compatible with.
Why — Karim went through three rounds (the current look, a hired designer's Figma, then Stitch) and this is the first one he liked. A first pass that borrowed its colours onto the existing layout was rejected within the hour ("nowhere near"); the layout IS the design.
Rejected — the Stitch mobile-home mock (a flat, tab-barred list; Karim praised the gradient page and ruled out bottom tabs, so the phone stacks the desktop page instead); Rubik everywhere (the home mocks are Lilita One + Fredoka and the in-play mocks are Rubik — the tokens carry all three, each screen wears its own mock's face); keeping legacy token aliases so untouched screens keep compiling (a mechanical rename across 118 files replaced them, and the accent ids stayed because the engine owns them).
What would reverse it — Karim disliking the next screens in the same language; the rollout is one screen per review on the tracker artifact, so a reversal costs one screen, not the app.
@theme, values per appearance in @layer themeDecision — every ground, card, text and line colour is a theme token with
a dark and a light value, and the player picks system / light / dark from the home header
(remembered by ~/device, applied before the first frame). The names are declared
in @theme with their dark values AND in both @layer theme variants —
the dark block is a literal duplicate of the @theme values.
Why — Tailwind only builds a utility (bg-app,
text-ink) for a name it sees in @theme; a token that lives only in the
layer resolves at runtime but styles nothing (see learnings.html). uniwind's rule
that both variants must declare the same set keeps the dark block. Karim asked for a switch
rather than system-only: the default follows the phone, the player can override.
Rejected — placeholder values in @theme (a lie in
the file the next reader trusts); omitting the dark layer block (breaks uniwind's
same-set rule on native, untested); dark: prefixes on every class (does
not scale past one screen).
What would reverse it — uniwind generating utilities from
@layer theme names directly; then the @theme copies go.
Superseded on 2026-10-13 by a game's card leads to its page: the mode sheet is gone, and a game's tap opens its page, which has both ways to start. The carry into pass-and-play stands, from the page's "Play on one phone".
Decision — tapping a game on home opens its mode sheet (built from the same Same Device / Online Room door cards the LAN route and catalog use; a single-mode game routes directly). Choosing Same Device carries the tapped game's id in the URL, so the who's-playing setup opens on it — the Shopfront's per-game "Same device" link appends the same id. The seed is spent once a match runs, so "New game" offers the game the group just played, not an hour-old poster tap. The ONLINE door opens the create-room screen with no carry for now: that screen is queued for its own redesign, and Karim chose to land the online carry with it rather than thread it through a screen about to change (a working version was built, reviewed, and deliberately backed out the same day).
Why — the tap IS the player's game choice on the same-device path; online, wiring a screen that is about to be redesigned would be built twice.
Rejected — storing the intent in app state or storage (the URL carries it, survives reloads, and works from the Shopfront); keeping the online carry live meanwhile (it touched the sign-in return URL, the room route and the lobby's seed order — too much plumbing to maintain under a screen that will change shape).
What would reverse it — the create-room redesign landing; its spec should re-thread the id through room creation and the lobby preselection (host only, with the host's own mirrored pick still winning — see the lobby seed comment).
Decision — the same-device build auto-started the next turn the moment the phone changed hands, on the reasoning that the pass-the-phone gate was already the actor's "I'm ready" tap. Review showed the cost: skipping the between-turns beat also skipped the reveal card — the prompt, who got it, the points — which is the payoff moment of a charades turn, and the shortcut was unreachable-by-test (every gate stays green; only playing the mode shows the missing beat). The auto-start is deleted: every mode now walks the same between → pick → act loop, and the next actor taps one button after seeing the reveal.
Why — a beat the whole room watches outranks saving the actor one tap; and the shortcut required the game's screen to know which mode it was in, which the board contract forbids for exactly this reason.
Rejected — showing the reveal on the hand-off screen (the hand-off is shared chrome that must not know game specifics); keeping auto-start but inserting a timed reveal pause (a timer where a tap belongs, and two moving parts to dodge one tap).
What would reverse it — playtests showing groups experience the extra tap as friction rather than a beat; the reveal would then need a home that is not the between screen.
Decision — the hired designer's Figma proposes Home / Games / Search / Profile bottom tabs. The app ships her visual language but not the tab bar: ONE home screen — header with sign-in, a create/join strip, a hero carousel of the newest games, and the full searchable poster wall — with the account in the header. Her screens are a reference, not a spec; Karim's tweaks win. The home renders one centred column at every width for now; the desktop layout is a later screen in the same review queue.
Why — with one real job (pick a game, start a room) a tab bar is chrome without content: Games and Search are the same wall, and Profile is one header chip. Fewer destinations also keeps the redesign reviewable one screen at a time.
Rejected — her bottom navigation (four destinations for one job); her purple-gradient splash ground (violet gradient grounds are a standing design ban); keeping the old neon mode signs (mode choice is moving into each game's sheet, so the home sells games, not modes).
What would reverse it — a real second destination (a profile with history, friends, or stats) would earn navigation back; traffic showing players cannot find search.
Decision — a room deals colours round-robin and keeps cycling past the 8-colour palette (a 30-member room cannot be unique in 8), so two members can share a colour — and a game can seat both. On a board where colour IS ownership (Chain Reaction's orbs, a token), that makes two players indistinguishable. The fix is a pure, deterministic display remap of the seated subset only, applied where each mode assembles its match roster: the first seated claimant of a colour keeps it, later seated duplicates take the lowest colour no seat holds. Seats are capped at the palette size, so it always succeeds. The room's stored colours stay canonical — the lobby never changes, and in a room of eight or fewer the remap is a no-op.
Why — it fixes exactly the surface that is broken (the match), touches no server state, and every client computes the same answer from the same roster order. Same-device cannot collide at all (colour = seat index, at most eight seats), so it applies only where the problem exists: online and LAN.
Rejected — rebalancing room colours when members leave (churns everyone's lobby identity to fix a match-time problem, and still cannot make 30 members unique); warning the host in the seat picker (pushes bookkeeping onto the host that the system can solve silently, and does nothing for auto-seated games).
What would reverse it — per-player custom colours or a bigger palette, which would make room colours unique again and the remap dead code.
Decision — two rulings from the same evening. One: a game must hold a group for 10–15 minutes (five is the floor). What meters its length has to be named at design time, and it must be something outside the room — questions in a bank, numbers called, a finish line, lives — never the room's own size. Two: the first game built under that bar is the Codenames-style team word game, shipped as Spy Grid with our own name, our own 1,289-word bank, and our own look (pink vs cyan teams, one assassin — or two, the host's call).
Why — Karim played Eliminate and it was over in under a minute, every time, at every table size: any game that drops a share of the room each round ends in "how many times can you halve the group", which is 2–5 rounds no matter what you tune. Short games throw players back into what-do-we-play-next friction and make the app feel thin. The games that survive real evenings — Tambola, Trivia Clash — are the ones where content is the clock and talking stretches it. Spy Grid's clock is table talk itself. The rename is practical, not cosmetic: mechanics aren't ownable, but the name "Codenames" and its word lists are, and a store takedown is a stupid way to lose a launch.
What we rejected — patching Eliminate with lives (three lives triples the length and still lands near three minutes for a five-friend room); a points-only no-elimination variant (a points race isn't an elimination game); keeping just the Rock-Paper-Scissors mode (fails the same math). The verdict, made after Spy Grid shipped: Eliminate is removed entirely — folder, registrations, catalog entry, poster, its orphaned sound. Pre-launch deletion is free and git is the archive; its real legacy stayed in the engine (readHostRun, the system-timeout guard, the host-run pattern every game now copies). Also rejected: cloning the official Codenames word list (copyrighted, and our bank can be authored for multi-meaning words on purpose), and giving Spy Grid timers — the discussion IS the product, so v1 arms no clocks.
What would reverse it — real tables telling us 12 minutes is too long for casual rooms (then a "quick match" preset shrinks the grid, not the rule), or the trademark landscape changing enough that the name question reopens. The duration floor itself only reverses if players demonstrably choose sub-minute filler games — nothing so far says they do.
Decision — GameResult gains an optional
teamWin: { label, accent }. A team game returns it and every winner screen
crowns the label ("PINK TEAM takes it!") with an accent crest, treating the several rank-1
rows as teammates. Alongside it, the two 138-line near-twin winner phases (online and LAN)
collapsed into one shared WinnerPanel, and the four hand-copied pulsing status
dots into one PulseDot.
Why — the winner screens derived "who won" from rank 1 and read several rank-1 entries as a draw, so the first team game would have ended every match with "Dead heat!" and no confetti. Fixing it per-shell would have meant the same edit three times — the exact drift the mode-shells rule exists to prevent, and the scout pass found the same-device shell had already drifted (it silently dropped badges).
What we rejected — faking it from inside the game (one representative rank-1 player misrepresents the podium); a full team concept in the engine (seats, team scores, team turn order) — too much machinery when games can own their team state, as Spy Grid does in plain game state with zero engine changes.
What would reverse it — several more team games each reinventing team
rosters in game state would argue for promoting teams into the engine contract properly;
teamWin is deliberately the smallest step, not the last one.
Decision — the brand mark is the two Gs on the ink field
(#0D0D1A): left G pink, right G cyan, both carrying a soft neon glow, no pink
outline. It is the app icon on iPhone and Android, the favicon, the splash mark, and the badge
in both wordmarks (App and Shopfront) — the badge is literally the app icon in miniature. One
master drawing feeds every size: the iOS Icon Composer bundle
(apps/app/assets/g4g.icon, wired through ios.icon), the Android
adaptive layers, the favicon family, and brand/logo.svg. The old single-pink badge
files (logo-pink/cyan/white) and the Expo starter artwork are deleted. The app's
home-screen name is "GG" — the full name truncated on Android and crowded on
iOS.
Why — three reasons, one per change. The outline went because a phone crops every icon into its own rounded shape and would shave a drawn ring unevenly at the corners — the crop already does the outline's job. The second G went cyan because two colours quietly say "two players", and pink+cyan are already the brand's primary pair. The glow stayed because it is the one piece of arcade flavour that survives at home-screen size — nine concepts were drawn and compared at real size before choosing.
What we rejected — the console-imagery concepts (gamepad, joystick, handheld, arcade buttons): charming, but every game app on the store is a gamepad, and the GG is ours alone. A single G beside a d-pad cross read as "G-plus". The handheld died at real home-screen size. And shrinking the outlined badge inward so the ring survives the crop puts a ring inside a ring and costs the letters a quarter of their size.
What would reverse it — a mark that reads as too dark against dark wallpapers in real use (the outline is the cheapest way to give the icon an edge), or the glow smearing at small sizes on low-density screens (the crisp duo without glow is the fallback).
Decision — the crowd elimination game ships as ONE game, "Eliminate", whose host picks HOW the room shrinks before starting: Heads or Tails, or Rock Paper Scissors against the app, with every future "each round, someone drops" idea joining as a mode rather than a new catalog entry. The reducer's judge and the mercy rule (a round that would eliminate everybody eliminates nobody) are mode-agnostic by construction. On one shared phone the app is the referee: the room plays with real hands, the host flips and taps out the fallen.
Why — Karim's ruling during the draft: Heads or Tails, RPS and the reflex micro-rounds are the same game wearing different hats, and shipping them separately would dilute the catalog while each dragged its own board, registry entry and marketing page. Modes compound; micro-games fragment. It also gives each mode its own honest mode-matrix — a mode needing private simultaneous picks simply hides from the same-device picker while its siblings still play there.
What we rejected — separate mini-games (the first draft); and a "no winner" outcome. The mercy rule exists in BOTH play shapes because a host who faithfully taps out an all-wrong room must get a re-flip, not an empty podium — the review caught that the first implementation only applied it online.
What would reverse it — a mode whose rules outgrow the shared round shape (picking → reveal → shrink). The day a mode needs its own phases, it has earned its own game.
Decision — with no signal, tapping the make-questions button draws twenty questions at random from across the entire built-in bank and goes straight to the preview: ten to play, ten spares. The category picker that shipped earlier the same day is gone, and so is the topic box (there is no model to ask). The host is never told whether a model or the bank answered. An ordered pack now also walks into its unplayed spares on a rematch instead of replaying round one — which is exactly why the draw is twenty and not fifteen.
Why — Karim caught the arithmetic the same evening it shipped: a ~200-question bank divided by eight categories is roughly twenty-five questions per slice, and a group that plays two rounds of "Food" has seen most of the slice. One draw across everything gives several rounds before anything can repeat. The category chips looked like a feature and were actually a repeat machine.
What we rejected — keeping categories with a bigger bank (the bank will grow, but not eight-fold soon), and letting offline hosts type a topic that nothing could answer.
What would reverse it — a bank large enough that a single category is deeper than today's whole bank. At that point the picker returns as a genuine choice rather than a trap.
Decision — a game can now declare rules.packSpec: what one item
of its content is, the shape it takes, and how to judge one valid. Declaring it is the entire
opt-in. The platform then supplies the topic box, the generator, both providers, the host's
preview and the write-your-own form. Trivia Clash declares it in ten lines. A second content game
will add zero functions to convex/.
Status: shipped 2026-08-16. convex/packs.ts, convex/lib/ai/generate.ts,
convex/lib/aiCredits.ts, and the lobby's pack screens under
apps/app/src/modes/online/.
Why — the questions run out. That is a content problem that no amount of authoring fixes, because a group that plays every week exhausts any bank we can write. Letting the host name a topic turns a fixed library into an endless one, and letting them write their own covers the cases a model cannot: an office in-joke, a family quiz, a classroom topic. Building it as a platform capability rather than a Trivia Clash feature is the whole point — half the roadmap is content games, and each one would otherwise re-implement the same screen.
Three calls inside it worth naming. Gemini is primary and DeepSeek the fallback, because Gemini can be held to a response schema and DeepSeek only promises valid JSON — but both are judged by the game's own validator, so the fallback is never a lower bar. The preview is mandatory: a generated answer can be wrong, and a wrong answer reaches thirty people with nobody in the room able to correct it, so the host reading the list is the safeguard rather than a nicety. Metering ships before billing does — every generation writes a row stamped with the host's account, in whole credits, with tokens recorded separately. Nothing charges anyone today; when it does, one gate function changes and nothing else in the codebase learns about money.
What we rejected — deleting the built-in questions entirely and making every game start with a generation. It was briefly the plan and it was wrong: it costs every single game either thirty seconds of waiting or several minutes of typing, and destroys the "let's just play something" moment the app is actually good at. Also rejected: generating during play (it would put a network call inside a live match and break the purity rule that lets one rulebook referee every mode), and trusting the client's pack (a host could hand themselves an answer key, so the pack is read server-side at start and reserved keys are stripped from whatever the client sends).
What would reverse it — generation costing enough to matter (at current prices a set of questions is a fraction of a cent, which is why brakes exist for abuse rather than for cost), or hosts consistently preferring their own questions, which would make the topic box the secondary path rather than the headline.
Decision — two changes to the game contract and one to Trivia Clash.
rules.carryOver(state) lets a game hand itself something to remember; all three
engines inject it into the next setup() under a reserved settings key, exactly like
the host id already is. Trivia Clash uses it so a room never replays a question it has already
seen. Separately, its easy/medium/hard split is gone — the field, the three pools
and the four-four-two draw quota. The bar is now "easy to medium", applied when a question is
written rather than counted at draw time.
Status: shipped 2026-08-16. The bank went 160 → 227 questions; 24 "hard" ones were dropped and
91 new ones added, each one fact-checked by a second pass before it was accepted.
Why — a rematch used to draw from the full pot again, so the second round of the evening could repeat the first. That is the most-noticed flaw in a party game played round after round, and it needed fixing at the platform level rather than inside one game, because every future content game has the same problem. On difficulty: it was never player-facing (this game has no settings spec), so nothing was taken from anyone. It only ever bought a fixed mix that made the draw more brittle, and it is the wrong axis for a party — the room wants questions it can shout answers to, not a graded exam.
What we rejected — a Trivia-Clash-private "seen questions" list, which would have worked and taught the platform nothing. Also rejected: throwing when the room finally exhausts the bank. It cycles instead. A repeated question is a far better outcome at a party than a Play button that refuses to work, and the memory resets with the cycle so the next rounds are fresh again.
What would reverse it — a game whose carried memory is genuinely large would need somewhere better than the settings blob to keep it (the contract caps it deliberately). And if players ask for a harder mode, difficulty comes back as a real host-facing choice rather than a hidden quota.
Decision — rules.actorFor(state) lets a game name the seat whose
action this device is sending. A game that returns a fixed seat is telling the shell the phone
never travels: every tap dispatches as that seat, and the hand-over screen never appears. The
same-device name list rises from 8 to 30 (narrowed per game by its own seat count), and Trivia
Clash becomes playable on one device — the host reads each question out, the room shouts, and the
host taps whoever got there first.
Status: shipped 2026-08-16. packages/engine/src/types.ts,
apps/app/src/modes/local/, and HostRunBoard.tsx.
Why — the only thing stopping a thirty-person game on one phone was an assumption baked into the shell: that same-device means a board passed hand to hand. That is true of Ludo and false of a quiz. Encoding "who acts" as a question the game answers, rather than something the shell infers from turn state, removes the assumption without changing a single turn-based game.
It is a different game there, not a reskin. No clock, because the host sets the pace. The correct answer shown openly, because the only person looking at the screen is the one who has to read it out and judge. Flat points, because there is no per-player timer to measure speed against and the host's reaction time is not the players' to be scored on. And "Nobody got it" as a real button, because sometimes the room is stumped.
What we rejected — a bulk paste-in name box for thirty players, which
Karim turned down: the existing one-row-at-a-time entry auto-focuses the next row, so a group can
rattle through names in one pass. Also rejected: a flag on the game meta saying
"host-run". A flag describes a category; actorFor answers the question the shell
actually needs answered, and one future game may well want to vary the actor.
What would reverse it — a same-device game that needs BOTH a fixed operator and a private hand-over would break the current simplification, since skipping the hand-over screen is tied to naming your own actor.
Decision — the mode matrix no longer claims that LAN and same-device are both
offline. LAN is offline by definition: the host's phone is the server and there
is no internet path, so anything needing a network is impossible there. Same-device is
not an offline mode. Whether a game reaches the network is the game's own property, the
same way each game already declares which modes it supports. A same-device game may use the
network — typically a setup step that runs before play begins — as long as it says so.
Status: corrected 2026-08-16 in design gate 1 of CLAUDE.md, principle 12 in
docs/principles.html, and the Same Device card in
docs/architecture/index.html. Every same-device game shipping today still needs no
network; the claim was wrong as a rule, not as a description of the current catalogue.
Why — the wording was load-bearing, not decorative. Gate 1 runs before every architecture proposal, so a false constraint there silently kills valid designs and nobody notices, because the rejection happens in someone's head before anything is written down. It already did: an AI-generated question pack reads as impossible under the old wording, since generating questions needs a network. It is not impossible. Generation happens once, before kickoff, and the match itself is offline afterwards. What actually separates the three modes is where the match authority lives, not whether a network exists.
What we rejected — keeping the old wording and carving out per-feature exceptions. That converts a clean gate into a growing list of special cases, and the next reader still meets "same-device is offline" first and stops there. Also rejected: deleting the offline clause from the gate altogether. Half of it is genuinely load-bearing — LAN really is offline, and forgetting that is exactly how a network call ships into the one mode that cannot make one.
What would reverse it — nothing about same-device; this is a factual correction rather than a preference. The surviving half would only need rewriting if LAN ever gained an internet path, which would defeat the reason LAN exists.
Decision — the content site's front page now wears the same design the app ships on its home screen: the poster wall of playable games as the hero, an action rail beside it with the brand mark, a bare "Play together" headline, the two neon mode signs and the join-code booth. Three things in the approved mock were not carried over, and one thing the mock does not have was added.
Dropped: the marquee poster. The mock enlarges one poster to a 2×2 block every nine games and stretches the last row's leftovers to close it flush. Deciding which poster gets enlarged requires measuring how many columns the browser actually resolved, which can only happen after the page comes alive — so a page delivered as finished HTML would paint an even wall and then visibly rearrange itself in front of the reader. The app's wall is uniform for its own reason (it has no grid to span), and one home should not be laid out two ways. The cost is a gap of one or two slots in the last row at some widths, which reads as a normal shelf.
Dropped: coming-soon games. The wall shows only games you can play right now. Twenty-one unfinished ones dimmed on the front door is a shelf of things you cannot buy; they keep their catalog pages, reached by the one quiet "All games" link on the shelf itself.
Added: a one-line legal strip. The mock has no footer, and an app screen needs none — but the privacy and terms pages are the ones Google's sign-in consent screen links to, and with no link from the front page they would be pages nothing on the site points at. So it is four small grey links and a copyright line under the wall, not the old four-link block with its own wordmark. This is the one place the shopfront's job as a website outranks the mock.
Why plain CSS and not the utility classes the rest of the site uses — almost
nothing on this page is a utility. A lit tube is one shape stroked three times; the lettering
carries a hairline outline under stacked glows; and every sign reads its colour from one variable
so the same component is pink on one and cyan on the next. Written as utility classes, each of
those needs a separate hand-written class string per colour, because the tool cannot see a name
the code assembles — a much bigger surface for identical pixels. The page keeps one prefixed
stylesheet; spacing, colour and type still come from the shared design tokens.
Status: shipped 2026-08-16 — apps/marketing/src/routes/index.tsx and its
-home/ parts, plus src/styles/home.css.
What would reverse it — the marquee comes back the day the wall is big enough that an even grid reads as a wall of wallpaper, and it can come back honestly if the enlarged poster is chosen from the catalog rather than measured from the layout. The coming-soon games return to the wall if we ever want the roadmap to be the pitch. The legal strip goes the day another always-visible surface carries those links.
Decision — holding a room code no longer puts you in the room. It earns you
the right to knock: the server records that you are waiting, your name appears in
the host's lobby, and the host taps Approve or Deny. Approve creates the seat
(with the colour the room assigns at that moment). Deny removes the request and records the
device and the display name on that room's ban list, so the same person cannot knock again and
wear the host down — the guest sees a plain "the host didn't let you in" with no retry button.
A player who is already seated is never sent back to the door: a reconnect after a
dropped signal or a closed app works exactly as it always did, mid-game included.
Status: shipped 2026-08-14. rooms.join is now rooms.knock, joined by
approveKnock / denyKnock (host-only) and a knockState
subscription the waiting guest watches; two new tables, knocks and
roomBans, both reaped with their room.
This is what made 4-character room codes affordable, which is the same decision seen from the other end. Six characters read as "too big to share" for a code that is spoken across a table and typed out of a group chat, and shareability is the product's front door. Four characters is about 810,000 combinations — small enough that a script could sweep every one of them, which is precisely why we moved up to six two weeks earlier. What changed is not the arithmetic; it is what a correct guess buys. It used to buy a seat. It now buys a row in a stranger's approval queue that the stranger will decline.
Why — the honest description of the old model was "anyone who ends up with this code is in the game", and a room code travels: it is read aloud in a noisy room, screenshotted into a group chat, left on a shared screen. Every alternative we had for that problem was a way of making the code harder to obtain, which fights the one thing the code is for. Asking the host instead solves it at the right layer, and it is a question the host can always answer — they can see the name, and they know who they invited. It also makes the room's guest list a thing the host owns, which is what a party is.
What we rejected — banning by IP address. The obvious way to make a deny stick, and we cannot do it: a Convex mutation has no visibility of the caller's network address, so there is nothing to record. The ban is therefore device-id plus display-name, both of which a determined person can change in under a minute. We accept that, because the ban was never the thing keeping anyone out — the host's tap is. The ban's only job is to make one "no" mean one answer rather than an argument. Longer room codes as the security mechanism — rejected on its own terms now: length was only ever protecting a door that should not have been unlocked, and a code long enough to matter is a code nobody wants to read out. Making approval a per-room setting ("let anyone with the code straight in") — parked, not rejected: it is a switch we can add the day someone wants it, and shipping it now would mean designing the private path and the open path at once, with nobody yet asking for the second.
What would reverse it — public rooms shipping. A directory of open rooms strangers are meant to walk into makes "the host approves every arrival" the wrong default for those rooms, and join-without-approval comes back as a room setting rather than as the behaviour of every room. Short of that: hosts telling us the approving is tedious at a big party, which is a signal to add a "let everyone in for the next few minutes" window rather than to remove the gate.
Decision — joining a room will no longer involve choosing a player colour.
The server assigns the next free colour from the same eight-colour neon palette, the picker UI
and every "that colour is taken" check go away, and the lobby roster shows only the people who
are actually in the room — no empty-seat placeholders, no "+N waiting" chips. In rooms bigger
than the palette (the big-group games), colours repeat past eight, which is harmless because
those games identify players by avatar and name, never by a board token.
Status: shipped 2026-08-14. rooms.join no longer takes a colour and assigns the
lowest-indexed least-used one (convex/lib/colors.ts); COLOR_TAKEN is out
of the error vocabulary; JoinGate is a name field, and renders a full room as a room
rather than a dead end.
Why — a guest's join should be one step: type the code, type a name, you are in. Picking a colour is a decision nobody came to the party to make, and validating contested colours was code that existed only because the choice existed. An empty-seat display answers a question ("how many CAN come?") that matters less than the one the lobby should answer ("who IS here?").
What we rejected — random generated colours. They promise infinite players but deliver near-duplicate hues and muddy values on a dark page, and keeping them distinct needs colour-distance code — more machinery than the picker it replaces. Also parked, not rejected: spaced-hue generation (each joiner one golden-angle step around the wheel at fixed neon saturation) — unlimited, always-distinct colours with zero validation. It changes what a player colour IS (a computed value instead of one of eight known tokens), which touches every game board's tinting — an engine-level upgrade for later, not a lobby redesign ingredient.
What would reverse it — groups genuinely caring about "their" colour across game nights (then colour becomes a remembered device/profile preference, still not a picker at join), or rooms beyond eight players where avatar-colour repeats measurably confuse people — which is the moment to do the spaced-hue engine work.
Decision — the first release offers Same Device and
Online only. Every line of LAN code stays exactly where it is and keeps passing
its tests; a single constant in each bundle (apps/app/src/modes/launchModes.ts and
apps/marketing/src/launchModes.ts) hides the mode from players, and a test forces the
two copies to agree. Resuming is one flip each. Full come-back note:
lan-pause.html.
Why — on an all-iPhone group the mode cannot keep its promise, for two reasons that are Apple's and not ours. A hotspot with no internet is marked "no internet" by every guest iPhone, which then routes its traffic over its own mobile data — so the host's phone, reachable only over that wifi, disappears and a browser guest gets a blank page. And airplane mode disables Personal Hotspot on iPhone by design, which deletes the flight scenario the mode was most quotable for. Shipping a third mode that fails at a table of iPhones costs more than not shipping it: a first impression, and the "it just works" claim the other two modes actually keep.
What we rejected — fighting the platform. Both behaviours are deliberate OS design, and every bypass we could imagine either asks the player to change a system setting we cannot change for them, or does not exist. Also rejected: phone-to-phone radio (Multipeer/Bluetooth), which sidesteps both limits but only for players who installed the app — and "friends join with nothing installed" is the entire point of the mode, so that is a different product wearing the same name. And rejected: deleting the code. It works on shared wifi today, it is fully tested, and the limits are about phones at a table rather than anything wrong with the design.
What would reverse it — either of two things. Traction plus the small honest fix: a help card telling a stuck guest to turn their mobile data off (never built), together with binding app-installed guests to the wifi interface in code (also never built, and it does nothing for browser guests). Or evidence that all-iPhone groups are asking for the flight scenario loudly enough to justify an app-only radio transport as a second way in, alongside browser guests rather than instead of them.
Decision — the LAN guest's reconnect has no last attempt. It ramps to a
five-second interval and stays there indefinitely, showing "Waiting for the host — you're still
in the game". The session ends on exactly two things: the player leaving, or a new
closed frame the host broadcasts when hosting genuinely stops.
Why — the old schedule gave up after 12.4 seconds and told everyone the game had ended. On a hotspot the server is a phone on the table: it locks, it takes a call, it goes to the kitchen. Twelve seconds is a normal pause, not an outage, and the app was already promising as much on the pause screen — "no need to rejoin" — twelve seconds before contradicting itself. Seats survive far longer than that (a 90-second grace, and longer while a match is live), so the client was throwing away a session the host was still holding open.
What made it possible — a heartbeat. Retrying forever is only useful if a
drop is noticed, and a wireless drop does not close a socket: it goes half-open, no close event
fires, and every recovery path in the guest hangs off that event. The ping frame had
been in the protocol from the start with nothing sending it; the guest now pings every five
seconds and treats fifteen seconds of silence as a drop.
What we rejected — a longer timeout. Any number is a guess about how long a friend is allowed to be away, and picking one only moves the moment the app lies. Also rejected: inferring "gone" from a failed reconnect, which cannot tell a locked phone from a closed room. Saying it explicitly costs one frame.
What would reverse it — evidence that a phone left retrying all night is a battery problem. A five-second ping on a LAN is cheap, but if it shows up in testing, back off the interval over time rather than reintroducing an end.
Decision — joinRoom no longer refuses by room state. Someone who
arrives during a game takes a seat in the roster, watches (seats were frozen at
startGame), and is dealt into the next one. ROOM_CLOSED leaves the LAN
error vocabulary along with ROOM_FULL and COLOR_TAKEN.
Why — the guard was status !== 'lobby', copied from Convex,
where refusing a stranger holding a code mid-match is sound. Here the arrival is a person sitting
down next to you. The negation also swallowed a third state nobody was thinking about: a room is
lobby, playing or ended, and ended is the winner screen — the exact moment
people wander over to see who won was the moment they were turned away. Every piece needed to
admit them already existed and was tested: frozen seats, a filtered view per player, a
"Watching" chip in the play shell.
What we rejected — admitting only in lobby and
ended. It fixes the embarrassing half and keeps the arbitrary half, and it is one
more three-state guard to get wrong later.
What would reverse it — a game whose reducer cannot tolerate a roster growing
mid-match. None can today (seats are fixed at start, and every game already implements
onPlayerRemoved for the shrinking case), but a future game that seats people
dynamically would need the rule revisited rather than worked around.
Decision — appVersionSource is local:
app.json holds the iOS build number, git tracks it, and
autoIncrement raises it at build time.
Why — releases are built on this Mac and uploaded straight to Apple, so EAS never learns what shipped. Its remote counter had drifted to 8 while App Store Connect already had build 10; the next automatic increment would have produced a number Apple rejects. A counter that is not told about most releases is not a source of truth, and the command to correct it is interactive, which is its own tax. Apple is the authority on what exists; the repo is the authority on what we are about to build.
What we rejected — keeping the remote counter and resetting it before each release. That is a manual step whose only purpose is to repair a mechanism meant to remove manual steps.
What would reverse it — moving releases back to EAS cloud builds as the normal path, where the remote counter is fed by every build and the drift cannot happen.
Decision — modes/online/session.ts became
src/device/identity.ts (session id + remembered profile), fronted by a
~/device barrel, and one useNameField hook is now the only
implementation of "prefill the name unless they have typed". All three name inputs use it:
online host, online join, LAN host, LAN join.
Why — the store was already device-scoped; only its address said otherwise,
and an address is not a detail. Two things followed from it: the LAN screens could not
reasonably read a name out of the online mode, so they started blank and every hotspot
night began with everyone retyping a name the phone already knew — the most repeated interaction
in the product. And the LAN transport was already importing
generateSessionId from the online folder, which is the same smell with the same
cause.
What we rejected — importing ~/modes/online/session from
~/modes/lan/. It would have shipped the prefill today and left a shell reaching into
a sibling shell, which is the rule that keeps the three modes independent. Also rejected:
duplicating the prefill effect a third time — there were already two subtly different copies (one
using state for "touched", one a ref; the ref is correct, because a keystroke landing in the same
tick as hydration must win).
What would reverse it — nothing about being online, LAN or same-device changes the answer to "what is your name", so this only reverses if profiles become account-scoped rather than device-scoped (a signed-in player carrying a name across their phones), which would move the store to Convex for signed-in users and keep this as the fallback.
Decision — defaultSettings takes 'local' | 'online' |
'lan'. The LAN host screen passes 'lan'; it used to pass
'online' because there was no other word for where it was.
Why — every game answering that question was answering one it had never been
asked. Quick Draw is the live example: it reads mode === 'local' to choose the shared
easel over typed guessing, and it gets LAN right today only because LAN and online happen to want
the same thing (everyone holds their own phone). The first game where they differ would have had
no way to say so, and the bug would have looked like a game bug rather than a missing word.
What we rejected — a boolean like oneSharedScreen. It answers
today's only question and hides the mode, which is the thing games actually reason about.
What would reverse it — a fourth mode, which would make an enum of shell names the wrong shape and argue for passing a small capability object instead.
Decision — the LAN host assigns each joiner the next unused palette colour,
and a hotspot room has no player cap. Both colour pickers (host setup and guest join) are gone,
join no longer carries a colour on the wire, and MatchHostOptions loses
hostColorIdx and capacity. ROOM_FULL and
COLOR_TAKEN are no longer part of the LAN error vocabulary — a code the host cannot
throw is a code the guest cannot receive — and the unminted LAN_FULL copy went with
them.
Why — both were online concepts that stop making sense once the referee is a phone on the table. A guest picking a colour is picking blind: no roster has been exchanged yet, so every joiner was shown the same pre-selected first swatch, which is precisely the one the host already held — the common case was a rejection. And the cap was 8 because that is Convex's default room size, a number that exists because online rooms are strangers sharing a database; a hotspot room is however many people are standing there, and being turned away at eight is a bug to them, not a rule. Karim's call, in both cases: remove it.
What we rejected — (a) keeping the picker but greying out taken colours, which needs the roster before the join and so inverts the handshake for a choice nobody asked for; (b) raising the cap to a bigger number, which keeps a limit that has no author — any number would be invented here rather than derived from anything.
What still limits a room — the game. Every game declares its own seat range
and engine.start enforces it, so Tic-Tac-Toe still seats two out of a room of
twenty; everyone else spectates, exactly as online. The palette wraps past eight players
(least-used colour first), so colour stops being unique and starts being a hint — acceptable
where everyone can see each other.
What would reverse it — evidence that a phone falls over serving a large hotspot room. That is a real limit worth measuring, but it is a device limit with a number behind it, not a copied default; if it appears, the cap belongs in the transport with that measurement cited.
Decision — a LAN/hotspot room no longer has a code. RosterSnapshot
drops the field, match-host stops minting one, and the four places that displayed it
are gone: the host's join card shows the QR and the address, the host's in-game header says "on
your hotspot", and the guest sees whose game they are in.
Why — nothing ever looked anything up by it. Guests reach a host by scanning the QR or typing the address; there is exactly one room per host, so there is nothing to disambiguate. The code existed only to make a hotspot room feel like an online room, and it was worse than decorative: showing a code implies a friend can join by typing it somewhere, which on LAN they cannot. Karim's framing: "why do we need a code for a hotspot room? It doesn't make sense. You just need a QR to be scanned or a link to be seen."
What we rejected — keeping it as a cosmetic room identity for visual parity with online. Parity is not a reason; it is how the concept got there. Where a hotspot room needs an identity, the honest one is the host's name.
What would reverse it — a LAN design where one host serves several simultaneous rooms, which would need something to tell them apart. Nothing suggests that is wanted, and a second host on the same Wi-Fi is already disambiguated by their address.
How it survived so long — it is the third self-consistent-but-pointless thing
Karim has caught after a thorough review passed it (with Trending and per-room display names).
Every engineering lens asks "is this built right?" and so takes existence as given. The rule now
in CLAUDE.md: explain a feature aloud in one sentence, and if the honest sentence
needs "…but it doesn't actually do anything" or "…it just makes it feel like the other mode",
that is the finding.
Decision — the LAN seat rule is: a dropped socket never costs a seat during a
live match, pending seats are freed on the transition back into the lobby, and a timer is the
backstop. Two cases are KNOWINGLY left open — a guest who leaves while the room sits in the lobby
can still be seated into the next game, and rematch (ended → playing, never touching
the lobby) can re-seat someone who left. Both are documented in the file and left alone.
Why stop — four attempts, each of which closed the named case and opened another: 10-second removal cost people their game when a phone locked; "only reclaim in the lobby" returned before arming and leaked seats forever; a re-arming 90-second poll lost the race against a host starting the next game; sweeping on every lobby emission collapsed the grace window to zero and evicted guests when an unrelated friend joined. Every one passed every gate. When the fourth attempt at one function is still wrong, the defect is the approach.
The real cause — RosterSnapshot has no connected
flag. The match host knows players, the transport knows sockets, and nothing joins them, so each
fix was another attempt to synthesise connectedness out of timers. Every synthesis has a
hole because the information genuinely is not there.
What we rejected — (a) A fifth timer refinement: same class, and by
then the evidence said it would open a fifth hole. (b) Reverting to the 10-second removal:
restores a bug that hits ordinary players (a locked phone) to avoid one that needs a guest to
actually leave and a host to restart within a window. (c) Adding the connected
flag tonight: it touches the pure package's types, the host screen and the frozen guest
bundle, and LAN cannot be verified without two physical devices — not work to do unattended and
unverified.
What would reverse it — doing (c) properly, with a device test. That is the only path that closes the remaining two cases, and it also earns a dimmed avatar for a dropped guest, which the host currently has no way to see at all.
Decision — apps/app/src/games/pure.ts is the single hand-maintained
list of what has shipped. convex/games.ts is now built from it rather than typed out;
the client registry and the marketing catalog are checked against it by tests. Adding a
game means editing pure.ts and registry.ts — never the server registry.
Why — there were three hand-typed lists of the same eight games and nothing comparing them, so they drifted silently. Word Search shipped and was playable for weeks with no page, no gallery card and nothing for Google on gamesforgroup.com. The test that should have caught it compared the catalog against a FOURTH typed list, in the test file, which had also forgotten it — so the suite agreed with the mistake. The same shape threatened online play: a game in the client registry but missing from the server one would appear in the lobby picker and fail with "Unknown game" in front of the host's friends, with every gate green.
What we rejected — (a) Have the marketing site import the game metas directly so the catalog carries no gameplay facts at all. Correct in principle, rejected because the rules are ~114 KB of logic and that site's entire job is to be a lightweight document for search engines. The metas are imported in its TESTS only, which is free. (b) A generated catalog file: more machinery than a failing test, and a generated file still has to be regenerated by someone who remembers.
What would reverse it — nothing foreseeable. If the catalog ever needs to describe games that do not exist in the app (a marketing page for something unbuilt), that is already supported: unshipped entries stay hand-written and simply are not cross-checked.
Decision — a dropped websocket marks nothing and removes nobody while a LAN match is in progress. Outside a live match the seat is reclaimed after 90 seconds, and if that window elapses mid-match the reclaim re-arms rather than giving up. An explicit Leave tap and a host removal are unchanged.
Why — it used to remove the player after 10 seconds, which ran
onPlayerRemoved: Tambola tickets dropped, turn rebased, and no way back in because a
started room refuses new seats. Phones suspend the page and its socket the moment the screen
locks, so this fired for someone sitting next to the host who glanced at a notification. Online
mode already answered this correctly — a drop dims the presence dot and nothing else — and it
made no sense for the harsher rule to live on the mode where the player is definitionally in the
same room.
What we rejected — removing the reclaim entirely, to match online exactly.
That is what the first attempt did, and it leaked seats permanently: a guest who closed their tab
on the winner screen stayed in the roster all night, and because startGame seats the
whole roster, the next match froze on a player who had left. The re-arming reclaim keeps the
protection without the leak.
What would reverse it — giving the LAN roster a real connected/disconnected flag (it has none; the host screen hard-codes connected). With presence on screen, the host can see a ghost and the automatic reclaim matters less.
Decision — the entire Trending feature is removed: the per-game counter and
its write on every engine.start, the gameStats and
trendingSnapshot tables, the roll-up cron, the stats.trending query and
the useTrending hook. The marketing home's rail keeps showing four curated games and
is now honestly labelled Featured. This supersedes the sharded-and-snapshotted
design recorded below, and the 7-day window that briefly replaced it — both are now history.
Why — it turned out to be connected to nothing.
useTrending had zero importers; the marketing rail was a hardcoded list (that site
carries no Convex runtime); the app's own home rail reads the game registry directly and is
called "Featured games". So the counter, the table, the ten-minute cron and the query fed no
pixel on any screen. On top of that, the feature cannot earn its keep for a long time: launch is
gated on having 20–30 games (there are ~10), and with a small catalogue and no players "trending"
and "curated" are the same four games. Karim: "I don't want a dead port to be in my app."
What we rejected — (a) Wire it up: make the site's rail read live ranks, baked in at build time. Real, but it spends effort on a signal that carries no information until there is real play. (b) Keep it dormant: keep counting so history exists when the feature is wanted. Rejected because it is precisely the dead wire he objected to, and because git history is a better archive than a running cron.
What would reverse it — a catalogue big enough that players cannot see every game at once, plus enough traffic that last week's play counts differ from the curated order. Rebuilding is cheap and the reasoning is preserved below: bucket plays by hour, sum a rolling window, shard the current bucket, serve one snapshot row, and refuse to rank below a noise floor. Do not resurrect the all-time counter.
How it was missed for so long — a full 12-agent security and scale audit
examined this feature twice (once for load, once for the ranking rule) and never asked whether
anything rendered it. That gap is recorded as a review rule in CLAUDE.md and as a
learning: a feature can be fast, correct, indexed, tested, and still be worth nothing.
Decision — room codes are 6 characters (31⁶ ≈ 887M, up from
31⁴ ≈ 923k). convex/lib/codes.ts is the source of truth; the three clients re-declare
the length the same way they already re-declare roomSize, since nothing can import
across the convex boundary.
Why — the code is the entire access control on a room: anyone holding
it takes a seat, and rooms.get answers for any code unauthenticated by design, so a
guest can check a link before joining. At four characters a script can sweep the whole space and
walk into whatever is open; the risk grows with popularity, which is precisely backwards from what
we want on launch day. Six keeps it readable aloud and inside the ≤6-char Press Start 2P rule.
Pre-launch there is nothing to migrate — existing codes simply age out with their rooms.
What we rejected — rate-limiting failed lookups instead. It is backend-only and needs no app build, which was tempting, but it defends a weak secret with a tripwire rather than making the secret strong; the app build was the actual objection and Karim removed it.
Separately, on the dev galleries: /fx, /catalog and
friends are no longer reachable in production, but their code is still compiled into the
web bundle. We measured the cost of removing it — a real export with the routes
pulled: 3609 KB → 3398 KB, 211 KB, about 6% (~60 KB
compressed). Declined. Capturing it means restructuring five route files so the
galleries survive in development, which is real regression risk for a saving that does not change
how the app feels next to the remaining 3.4 MB. Recorded because the number is the point: it
turns a recurring "we should really strip those" into a settled answer.
What would reverse the second one — a genuine bundle-size effort. In that context 211 KB is worth taking along with everything else, and the restructure is no longer a risk carried alone for a marginal gain.
Decision — the home "Trending" rail no longer touches a live counter from a
client query. engine.start bumps one of 16 random shards per game
(gameStats, by_game_shard); a cron rolls the shards into a
single trendingSnapshot row every 10 minutes; and
stats.trending reads only that one row.
Why — the unsharded, live version had two independent failure modes that both only appear under load, which is the worst time to find them. Write side: one document per game meant every concurrent start of the most popular game serialised on it, and a Convex mutation is a transaction — OCC retries would have surfaced to players as failed game starts, worst exactly at peak. Read side, the bigger one: the rail is subscribed by every client on the home screen, so every game start anywhere invalidated it for everyone — ten thousand viewers turn a trickle of starts into a flood of query executions the backend inflicts on itself. Trending is cosmetic ordering, so ten minutes of staleness is invisible; a stalled lobby is not.
What we rejected — (a) Delete the feature: the audit's own suggestion, since nothing critical reads it. Rejected because the rail is real product value and the fix is cheap. (b) Move the bump to a scheduled function: takes it off the start path but leaves one hot document and does nothing about the read amplification, which was the larger problem. (c) An official rate-limiter component: solves abuse, not contention — wrong tool.
What would reverse it — needing genuinely live counts (a live "N playing now" badge). Then keep the shards and push updates through a purpose-built aggregate rather than re-pointing client queries at the counters.
The generalisable rule — recorded in CLAUDE.md: no client-facing
reactive query may read data that all users write, and no document may be written by every
participant on a hot path. Both are invisible at ten users and fatal at ten thousand.
Decision — convex/contact.ts is gone; contactMessages
drains via cron and its declaration goes when empty. The /contact page keeps composing a
mailto:, exactly as it already did.
Why — it was an unauthenticated public write, live to the whole internet, whose only spam control was a cooldown keyed on a string the caller chooses — bypassed by rotating one value. The obvious ticket is "add real rate limiting". The better question was why the endpoint existed at all: nothing in the product had called it since the site cutover. Hardening would have spent a dependency and ongoing complexity defending a door with no room behind it. Deleting an unused endpoint is strictly better than securing one.
What we rejected — mounting @convex-dev/rate-limiter with a
global cap (recommended by two of the three reviewers who found this). Correct advice for a live
feature; wrong for dead code.
What would reverse it — wanting a real in-product contact form. Then it comes back deliberately, with the throttle keyed on something the caller cannot pick, and a retention policy from day one.
Decision — the public rooms directory is removed. Every room is invite-only:
the server no longer accepts a visibility argument, the directory query and its index are gone, and
the client's browser screen and privacy toggles are deleted. The visibility field stays
declared (optional, unread) purely so historical rows still satisfy the schema.
Why — two reasons, product first. (1) A public board is only useful with enough traffic to keep it populated; at launch it would sit empty and make the app look dead. (2) It is a moderation surface — stranger-visible room titles and player names — that we are not ready to own, and abuse there lands on people who never opted into meeting strangers. The audit added a third: the directory handed any anonymous caller a live list of joinable room codes, which turned seat-squatting from "guess a code" into "read the list".
What we kept — the title sanitiser built for it (control characters, bidi
overrides, zero-width spoofing) moved to lib/roomTitle.ts and was
promoted to cover display names, which never had it and needed it more: a name is
how every board identifies a person.
What would reverse it — enough concurrent players that a browsable board is
reliably full, plus a decision about moderation. Re-adding means a writer, a
by_visibility_status index, and a content story — deliberately more work than flipping
a flag, so it can't return by accident.
Decision — the site is ONE address. The marketing/content build and the Expo
web export are merged at build time (scripts/merge-site.mjs → dist-site/)
and ship together on every push to main: content pages own /,
/games/*, /privacy, /terms, /contact; the play
shell owns /host, /local, /rooms, /room/*,
/lan/* via _redirects rewrites. The play subdomain, its Netlify site,
and its DNS record are deleted.
Why — three costs of the second domain, none offset by a benefit: (a) invite
links — the URL friends actually share — read play.gamesforgroup.com/room/CODE
instead of the brand; (b) the browser stores the sign-in token per origin, so the content pages
could never see logged-in state across two domains — no code fixes that; (c) the two
roots had to ping-pong (marketing linked out to play, play's root bounced back), and that bounce
already shipped one OAuth-losing bug (2026-07-17). Being pre-launch with no real users made the
switch nearly free: no shared links to preserve, no SEO equity to migrate, no uptime constraint.
What we rejected — (a) Keep two domains and bridge auth: a cross-origin token handoff is fragile, security-sensitive plumbing that still leaves the ugly invite URL. (b) Two Netlify sites proxied under one domain (apex rewrites to the play site): preserves independent deploy cadences, but that independence only matters with live users mid-session — plumbing without a payoff today. (c) Fold everything into the Expo app: rejected separately (see the content-site decision below).
Costs accepted — every push to main now runs the Expo web export
in CI (slower builds), and site + play app deploy in lockstep. The Convex-before-client deploy
order matters more, not less: the reducer deploy must land before the push, since the push now
ships the play bundle automatically. merge-site.mjs fails the build on unexpected
file collisions or a dev backend URL in the bundle.
What would reverse it — a real need for independent web-release cadences (e.g. a large team shipping the shopfront and the app on different schedules), or the play app moving off static export entirely. Nothing about scale alone: one domain serves any traffic the two served.
Decision — apps/marketing is not legacy and there
is no "final cutover" planned. It keeps one job forever: the pages that must rank on Google —
home, one page per game, contact, privacy, terms — prerendered to plain static HTML with per-page
title and description. It owns no gameplay; apps/app owns all three modes.
Why — search engines want a lightweight HTML document. The Expo web export
can emit HTML per route (web.output: "static"), but those pages are assembled
out of React-Native-for-web building blocks: a heavier download and a worse fit for a page whose
entire job is to be read by a crawler. Two codebases is a real cost, but it is paid in a place that
changes rarely (five page types plus a catalog row per game), and it buys the one thing an app
bundle is bad at.
What we rejected — (a) Fold the content pages into the Expo app and run
one codebase: cleanest to maintain, and it would delete the duplicated home page
(src/app/index.tsx is a hand-kept RN copy of the marketing home) and the duplicated
game list (games/registry.ts + catalog/games.ts). Rejected because it
trades away the SEO quality that is the site's only reason to exist. (b) Delete the content
site and accept whatever the app export ranks at — rejected for the same reason, harder.
What would reverse it — Expo's static web export producing genuinely crawler-grade pages (light payload, real per-route head, good Core Web Vitals), or the project deciding organic search is not a growth channel. Either makes the second codebase pure overhead.
Note — this decision is about codebases, not domains. Keeping
the content site does not require keeping play.gamesforgroup.com; two builds can serve
one origin — which is exactly what happened, same day (see the one-domain entry above).
Decision — Word Search ships as a single game folder. Its two ways to play —
Race (a found word crosses off for everyone; only the finder scores) and
Parallel (each player hunts their own tally on the same board) — are one host
select setting, alongside pack, board size, word-list visibility, and match length.
Selection is a drag (press the first letter, drag to the last), snapped to the
nearest of the 8 straight lines and previewed live; the reducer validates the endpoints with the
same lineBetween/matchClaim selectors the Board previews with.
playerView strips unfound word positions from the wire (and, in Parallel,
anonymises opponents' finds to counts) so the grid can't be trivially read from the network — while
accepting that a shown word list plus the public letter grid is inherently solver-solvable,
which is fine for a friends party game.
Why — Race and Parallel share the generator, packs, Board, and ~all of the
reducer; the only real difference is whether a find is keyed globally or per-player. Folding that
into a config keeps one registry line and one rules.test.ts, and matches how the host
already configures Tambola. Drag is the selection every word search uses; it reuses the sanctioned
single-Gesture.Pan pattern from src/ui/DrawingCanvas (pointer→cell mapped
by a module-scope worklet, geometry/snap on the JS thread, runOnJS to React), so it
stays inside the worklet-safety rules. Stripping unfound positions costs nothing and stops casual
network-peeking; chasing true anti-cheat on a shown-list word search is unwinnable and not worth the
complexity here.
Rejected — (1) two separate games (word-race / word-parallel): duplicates the generator, packs, and Board, needs two registrations, and contradicts "the host picks the mode." (2) Two-tap selection (tap first letter, tap last): simpler and gesture-free, but not the drag feel a word search is expected to have — the product wants the drag. (3) Making a shown-list board "cheat-proof": impossible once the grid and word texts are both visible — the whole game is locating them.
What would reverse it — a competitive/ranked context where solver-cheating actually matters (would push toward hidden-list-only, or server-issued per-cell tokens).
Decision — Documentation lives as HTML, not Markdown. Every file under
docs/ is .html sharing one stylesheet docs/doc.css (hand-written
semantic classes); only the agent-rules cards — CLAUDE.md, apps/app/CLAUDE.md,
AGENTS.md — stay Markdown. Two rules shipped with it: a doc describes how the project
WORKS, not its inventory (no game counts, build/version numbers, milestone status, or
deployment IDs — those churn and live in the runbook or nowhere), and superseded planning specs +
one-time feature-work artifacts (the whole docs/superpowers/, redesign/,
validation/, engineering/ trees) were deleted rather than left to rot.
Why — HTML renders in a browser, navigates with links, carries real tables and diagrams, and shares as a URL, so a doc actually gets read (the "unreasonable effectiveness of HTML" Claude-team approach — a Markdown file over ~100 lines mostly goes unread). The token cost the extra markup implies is neutralised by the shared stylesheet + semantic classes: each page is content + short class names, so reading a doc is reading prose, not CSS. And a stale doc that describes what was planned misleads a future agent worse than no doc — when git history is the archive, pruning beats hoarding.
Rejected — (1) staying on Markdown (the trigger: "it's very hard to read a Markdown
file"). (2) Tailwind / utility classes for doc styling — that spam IS the token bloat; semantic CSS in
one shared file is far leaner. (3) keeping the superseded specs "just in case" — unmaintained, they
drift into lies, and git history already holds them. (4) per-file inline <style>
blocks (the old architecture pages' ~70 duplicated lines each) — consolidated onto doc.css.
What would reverse it — a docs surface a browser can't render (back to Markdown-only tooling), or a single doc that genuinely must be a self-contained shareable file (inline its style then). A doc that truly needs live inventory would point at a generated source, not hand-hold a count here.
Decision — Spoken number-calling is now a per-device toggle in the caller
sheet, OFF by default — each player turns it on for themselves. The host no longer controls
it: the voiceScope state field, the setVoice action, and the lobby "Voice
plays on" setting are all removed. The Caller owns the speaking now (the Board no longer does), so the
toggle sits beside what it controls.
Why — testing on iOS Safari surfaced "no sound", and the real lesson was that it wasn't a bug: every browser deliberately refuses to speak until the user has spoken inside a tap (an auto-called, timer-driven number can't). Fighting that with an invisible auto-unlock was working against the platform. Making it an explicit per-person opt-in aligns with the browser's intent (the enabling tap IS the gesture that unlocks speech — we speak the current number right there), fits that "read aloud" is a personal preference not a room-wide one, and deletes code: a whole reducer action, a state field, a lobby setting, and the unlock hack.
Rejected — (1) the host broadcasting a voice scope through game state (the prior design, 2026-07-18 below) — it fought the gesture gate and imposed one person's choice on everyone. (2) a silent auto-unlock primed on the first stray tap — invisible, fragile across iOS versions, and still auto-plays against the platform's wishes. (3) keeping voice in the pure reducer at all — it never needed to be authoritative/shared; it's a local playback preference like the mute button.
What would reverse it — a genuine need for the host to force audio on every device (e.g. a single-room "hall" mode with one speaker), which would put a shared voice flag back in game state. This supersedes the voice half of the 2026-07-18 "voice + points ride in game state" entry (points still do).
GameModule.Overlay slot + an OverlayOutlet portalDecision — Tambola's called number left the board and became a floating, draggable
puck pinned to the viewport (~/ui/DraggableCaller + its countdown ring), so a player never
scrolls between the number and their tickets — the pain that grows with every extra ticket. Two pieces
of shared machinery make it work without breaking the mode-agnostic-Board rule:
GameModule.Overlay — an optional, lazy, mode-agnostic component
(GameOverlayProps: state + players + me + dispatch + pending) a game may ship alongside
its Board. The play SHELLS forward it; the game owns the pixels. It gained dispatch when
the caller's sheet became the host's control centre — pause/resume and call speed moved off the board
and into the sheet, so the overlay dispatches like a Board does (it stays otherwise a reflection of
state). (Spoken calling briefly lived here too as a host setVoice; it later became a
per-device toggle — see the 2026-07-19 entry.)~/ui/OverlayOutlet — a tiny in-tree "portal" (split set/node
contexts). A screen wraps its content in OverlayProvider and drops one
OverlayOutlet as the LAST child of its non-scrolling root (after the ScrollView),
box-none so touches fall through except on the puck. A shell inside the scroll calls
useOverlay(node) to publish; the outlet renders it pinned above the scroll.Why the outlet at all — the thing with the state (the shell) lives INSIDE the screen's ScrollView; the place a viewport-fixed overlay must render is OUTSIDE it. A 2-D draggable element inside a vertical ScrollView also fights the scroll (a swipe that starts on it drags instead of scrolls). The outlet bridges the two.
Rejected — (1) an absolutely-positioned overlay INSIDE the Board: absolute is
relative to the scrolled content, so it scrolls away, and it re-lands the pan-vs-scroll fight. (2) an RN
Modal: it's a separate native window, so touches never reach the tickets behind it —
daubing would break. (3) adding @gorhom/portal or another portal dependency: not worth a
new dep for one overlay. (4) putting the caller in the generic PlayScreen: it's game-specific and the
screen has no game state. The Board stays clean — it never learns the caller exists; a screen with no
outlet simply shows no overlay (graceful no-op).
What would reverse it — a real portal/overlay primitive landing in the stack (e.g.
an adopted FullWindowOverlay path that passes touches through), or the play screens moving
their scroll ownership INTO the shells, either of which would let a shell render the overlay directly
without the outlet indirection.
Decision — GameModule.devScenarios (dev-builds only): a game ships
canned mid-game states — stacked tokens, an imminent capture, one move from winning — and the
same-device shell shows a scenario picker (DEV in the header) that swaps the running
match's state directly (devLoadState, a __DEV__-gated store action that
bypasses the reducer). Why: the two ugliest bugs of this session (the unmounted winner animation, the
stacked-token mush) both lived in moments that take a ~20-turn game to reach — nobody watches those
often enough to catch regressions. First use validated the entire Ludo win flow (walk home → held
reveal → winner screen) in one scripted minute. Rejected: a debug screen outside the game (loses the
real shell around the Board) and seeding via special dice (can't express arbitrary layouts). Production
reads none of it.
Same session: tokens sharing a cell now shrink and fan into a small cluster (2 → diagonal pair, 3 → triangle, 4 → quad) so blockades and safe-square shares read like coins on a physical board instead of one disc hiding another.
Karim rejected the first shipped board ("single-stroke snakes are not acceptable") and drove six iterations of visual polish. The calls that came out of it, several of which now apply to every game:
boardArt.ts — deterministic,
unit-tested, ~650 lines of pure math). Skia was rejected for static art even though it's already
installed: its web renderer needs a ~7MB CanvasKit WASM the offline LAN guest bundle would have to
embed, and SVG renders identically on native/web/LAN for free. Skia becomes right if we later want
shader effects or path-following at 60fps.endedRevealDelayMs joined the GameModule contract after the winner
screen was caught unmounting the Board mid-animation (see learnings). All four shell swap sites honor
it; a game declares how long its finish needs.ownsTurnStatus now silences the local shell's banner too (it
already silenced online/LAN). A game that owns its turn story owns it everywhere; S&L's roster strip +
colored controls ARE its turn UI. Quick Draw's pass-device cue was unaffected (its HandoffScreen is
separate).lastNames
persisted through End game / New game) and pins Start in a sticky bottom bar — both from Karim's live
playtesting, both mirroring what the online lobby already did.Building three games in one night was the first real stress test of the "adding a game" story. Karim asked for an honest architecture review; these are the changes that came out of it (the architecture is early and evolving — we refine as we learn).
onPlayerRemoved is a required GameRules method, not a payload
convention. Removal used to reach games as a '@player-removed' action every
reducer had to remember to catch — a game that forgot compiled, passed its tests, and crashed the
first time someone quit mid-match online. Now the engines route removal to a required method;
TypeScript forces every game (and every future game) to decide, even if the decision is "unsupported,
throw." Rejected: an engine-side default removal (wrong-by-default for games with per-player state —
orphaned tickets/tokens); docs-only (the trap stays armed). Removal is deliberately NOT fakeable over
the wire: a @player-removed payload through reduce is junk like any other.
@timeout stays a reduce-dispatched action because timeout mechanics are game-specific;
removal semantics are universal enough to earn a contract method.game.ts exports, never stored in state and never inlined in Boards. The
alternative (storing e.g. state.legalMoves) was rejected because stored derived data
invents a staleness bug class — every reducer return-point must remember to recompute it (Ludo has
six) — and bloats the synced state. The real risk to kill early was Boards re-implementing rules
inline (Connect Four's Board briefly inline-checked column fullness); the convention makes the reducer
and the Board share one helper, so affordance and rule can't disagree. Reverses if a game's selectors
ever get expensive enough to need memoized-in-state caching — none is close.assets/posters/, ~650KB for
seven jpgs) instead of fetched from gamesforgroup.com/posters/. LAN mode promises "no
internet needed," and the remote fetch quietly broke that promise in the game picker. Metro emits each
jpg as its own asset file, so the JS bundle doesn't grow; unknown future slugs still fall back to the
remote URL. Rejected: icon-fallback-when-offline (kept the broken-by-default path) and bundling all 28
catalog posters (2.3MB for art of unshipped games).bun run new-game <id> scaffolds a game folder;
@g4g/engine/testing carries the shared test helpers (ctxFixed,
ctxRolling, expectGameError). Each of the three games hand-copied the same
~40 lines of test scaffolding and five-file skeleton, with small copy-paste mistakes each time. The
generator emits a compiling, test-passing skeleton implementing the full contract; the helpers module
is framework-free (no vitest import) so it can never leak a test dependency into a runtime bundle.guestBundle.test.ts):
every registered game id must appear in the built guest-dist export, else the test names
the missing games and the fix. This closes the "new game is invisible to LAN browser guests" gap found
the hard way (see learnings, 2026-07-18). When the export was never built on a machine, the test says
so and passes — EAS builds regenerate it via postinstall anyway.Decision — Design calls made while adding the three games, all playable on same-device, online, and LAN:
PATH.
The Board's cell geometry is a hand-written clockwise coordinate array; token positions map
(seat, rel) → a grid cell using the SAME absCell the pure rules use, so the
board can never disagree with the reducer. Rejected a rounded-ring layout (easier to compute, but
doesn't read as "Ludo"); rejected walking the corridor algorithmically (too error-prone to get the
four arms + turns right — an explicit table is auditable).result() ranking already orders the non-winners, so it's a small change to
keep going until only one is left.-1
yard, 0..50 loop, 51..55 home column, 56 home) rather than
absolute board coordinates. Capture/safe checks map to absolute cells on demand. This keeps the
reducer tiny and makes "exact count to finish" and "6 to leave the yard" one-line checks. Rejected
storing absolute (col,row) in state — it would push board geometry into the pure rules, breaking the
isomorphic-purity line.ownsTurnStatus, never optimisticMoves.
Their reducers call ctx.rng on the player action path, so an optimistic local reduce
would diverge from the server's roll — the exact case the contract forbids. Connect Four, being
rng-free on the move path and turn-gated, keeps optimisticMoves: true like
tic-tac-toe.What would reverse them — (1) a designer wanting a modern app-style round board; (2) player demand for full placings; (4) never — it's a correctness invariant; (5) a single-player/practice mode request.
Decision — Four coupled calls for the Tambola voice/scoring/achievements feature:
expo-speech), not recorded clips. Asset-free,
offline, and "forty-two" is trivial to say. Rejected shipping ~180 audio files (2 voices × 90 numbers)
— bloat + a render pipeline to replace code that doesn't exist yet.Board only ever receives state — there is no other channel from the host's
lobby choices to every client's board. So setup() reads the settings and stores
prizePoints, voiceStyle, voiceScope in state; the Board reads
them from there. They're inert data (no React/clock/rng), so the purity rule holds.numberInput settings field, not the existing
stepper. Karim wanted to type any value with no ceiling; the number stepper is
bounded by min/max/step. numberInput (min only, no max, no step) renders as a numeric
text field. Call-speed and ticket-count stay steppers — small bounded ranges where a stepper is
better.GameResultEntry.badges?: string[], not by
threading full state to the winner screen. The winner shells only get the generic
result; adding an optional badge list keeps them generic while letting Tambola's
result() hang each player's claimed-prize labels off their entry. Any future game can use
it."Female/male" is best-effort — expo-speech exposes no gender field,
only the installed voices. resolveVoice matches Android gender tokens and a curated set of
iOS voice names, and falls back to the default voice differentiated by pitch. On the record so a device
with sparse voices isn't a "bug" later.
Amendment (2026-07-18, same day) — the male voice sounded poor on device and the
female one good, so male was removed entirely (Karim: no dead/unused code). There is no
voiceStyle state, no style setting, and resolveVoice(voices) is female-only;
the caller always speaks with a resolved female voice (or the device default nudged up in pitch). The
winner is that the "best-effort gender" caveat above now only has to hold for one voice, not
two. Reintroducing a choice later means re-adding the style param + a settings select — a deliberate,
tested change, not a dormant branch.
Placement note — useNumberCaller lives in
src/games/tambola/, not src/ui/, so the generic UI kit never depends on a
game. It reaches out to ~/ui only for the shared mute flag.
Reverse if — we need identical, controllable voices across devices (go to recorded clips), or a second game needs configurable points (promote the point readers into a shared settings helper).
Decision — ios/Podfile.properties.json (gitignored) hand-sets
"ios.buildReactNativeFromSource": "true", so local Debug builds compile RN from source
instead of using the prebuilt ReactNativeDependencies core. EAS keeps the default prebuilt
core (the flag lives only in the local file).
Why — The prebuilt core is a release artifact: it omits the
RN_DEBUG_STRING_CONVERTIBLE-guarded debug symbols
(DebugStringConvertible::getDebugName, ShadowNode::getDebug*,
Sealable, RCTPackagerConnection, …) that the Debug-compiled Fabric pods
(gesture-handler, reanimated, screens, skia, svg, expo-dev-launcher) reference. Any clean local Debug
build died at the final link with ld: symbol(s) not found for architecture arm64 — on RN
0.86 / Xcode 26.6, with or without any recent change. Verified by nm over every built
.a: the symbols were referenced by six pods and defined nowhere.
Rejected — a Podfile post_install hook forcing
RN_DEBUG_STRING_CONVERTIBLE=0 in the consumer pods to match the release core. Faster
builds, but it strips debug introspection from every Fabric pod in a debug build and silently
diverges from how EAS builds them.
Cost accepted — the first from-source build recompiles all of RN (~15–25 min); clean rebuilds stay slower than prebuilt. Incremental builds are fine.
Durability twin — the audio library's FFmpeg-off / no-background-audio choices moved
into app.json as the react-native-audio-api plugin with tuned options
(disableFFmpeg: true, iosBackgroundMode: false, androidForegroundService: false,
androidPermissions: []). That survives any prebuild AND applies to EAS — without it,
cloud builds were shipping the FFmpeg bloat the local hand-patches only stripped
locally.
Reverse if — RN ships a debug-flavored prebuilt core (or the SwM pods stop referencing the debug-only symbols); then drop the flag and take the fast path.
Decision — The arcade SFX engine (src/ui/sound.ts) now plays on native
by handing its existing Web-Audio synth a native AudioContext from
react-native-audio-api. getAudioContextCtor() returns the browser context on
the web export and the native one on device (gated by navigator.product === 'ReactNative',
lazy-required so the node test run never loads it and stays silent). No sound logic changed;
no audio files added.
Why — Before this, every SFX (join/win/daub/…)
was silent on iOS and Android — the engine only works where
window.AudioContext exists, i.e. a browser. We'd only ever heard the sounds in the web
preview. The engine was already written against the Web Audio interface, so a native Web Audio
backend makes the identical palette play on device with a one-function change.
Rejected — Pre-rendering the palette to ~10 bundled clips played via
expo-audio (already installed). It would drop the Web-Audio dependency, but means shipping
asset files, a render step, and a second playback path to keep in sync — all to replace code that
already works. Not worth it when the synth just needs a runtime.
Turned off (config/build flags, not package removals — one package was added):
react-native-audio-api compiles in ~tens of MB of FFmpeg
for compressed-audio decoding we never do (we only synthesize oscillators). Excluded via
DISABLE_AUDIOAPI_FFMPEG=1 in ios/Podfile and
disableAudioapiFFmpeg=true in android/gradle.properties. Both native dirs are
gitignored, so these are hand-patches that a prebuild --clean wipes — same class as the
LAN modular_headers lines.react-native-audio-api plugin from app.json entirely — the native
module autolinks without it — which also avoids an app.json change that would trigger a
Podfile-clobbering prebuild.Reverse if — we ever need to play real audio files (music beds, recorded voice lines) or background playback: re-add the config plugin with tuned options and re-enable FFmpeg (or the smaller static decoders) for the formats used.
Status (2026-07-18, later) — the local-build blocker turned out to be a pre-existing
prebuilt-RN-core symbol mismatch, fixed the same day (see the entry above). The tuned config plugin in
app.json is now the durable home for the FFmpeg/background flags.
SITE_URL points at the play domainDecision — SITE_URL on Convex prod is
https://play.gamesforgroup.com (was https://gamesforgroup.com, stale from
before the play-domain cutover).
Why — The play app is the only thing that signs in; apps/marketing has
no signIn at all. The old value made relative redirects resolve to the marketing site.
Honest note — This was not the cause of the sign-in bug it was found chasing (see learnings.html); it was independently wrong.
Reverse if — the marketing site ever adopts Convex auth.
src/fx is a shelf, src/ui is in useDecision — Ported animation components live in src/fx (demoed at
/fx) and app code must never import them. Promoting one = move it to
src/ui, export it, demo it at /catalog, drop the /fx demo.
Why — Karim's call: keep a clear line between what the app actually uses and what's on the shelf for later, so the catalog stays an honest inventory.
Rejected — one merged gallery. It made "what do we actually ship?" unanswerable.
Decision — src/fx/motion.ts owns the vocabulary: EASE_OUT
for entrances, indicators, settles; SETTLE_SPRING (overshoot-clamped) only when a released
gesture hands over real velocity. Curves are written Easing.bezier(…).
Why — Karim: "every animation is moving a lot… it should ease out smoothly." Bouncy
springs read as rubbery. The bezier spelling is not cosmetic — the composed form silently
becomes linear on web (see learnings.html).
Deliberately exempt — signature celebration effects (particle burst, Tambola grid morph, liquid slider) keep their character.
scan:worklets uses the TypeScript API, no new dependenciesDecision — The worklet guard is a plain script on TypeScript's own compiler API rather than babel.
Why — typescript is already a dependency and resolves cleanly;
@babel/* doesn't resolve from apps/app under bun's isolated layout. A guard
that needs its own install is a guard people skip.
Trade-off accepted — name-based lookup, not full scope resolution, so it stays quiet
when two same-named functions differ. A checker that cries wolf gets ignored, so it is
deliberately biased toward silence over completeness. It also cannot see the React Compiler
_temp trap, which is documented instead.
Decision — The borderless/"filled" look landed as a variant prop on 12
primitives with byte-identical defaults.
Why — Karim: "i dont want any changes to the current ui of the app, that is for later." A variant axis lets the design exist and be reviewed before anything adopts it.
Decision — Component galleries are dev-only app routes (/catalog,
/fx), not Storybook.
Why — Storybook's web mode bypasses metro, which breaks uniwind styling — the components would render wrong in the very tool meant to show them off. A dev-only route renders through the real pipeline, so what you see is what ships.
Cost accepted — we hand-build the gallery chrome (src/devtools/).
Decision — Rebuild as an Expo app (iOS + Android + web) per the approved spec. Only
4 games ship. play.gamesforgroup.com serves the app; the legacy TanStack site keeps
gamesforgroup.com as the shopfront until final cutover.
Why — One codebase for phone and web, and a real native app for the stores. Splitting the domains let the app go live without touching the marketing site.
Consequence to remember — the two are different apps on different deploy tracks. The play site's root forwards to marketing, which has bitten us (see learnings.html).
rooms.ts / engine.ts
are generic; a game is a folder plus two registration lines. Keeps the backend from growing a bespoke
surface per game.