Everything the player sees and touches. The headline: one codebase becomes the iPhone app, the Android app, and the play website β and one clean split (Boards draw, shells connect) makes every game work in all three play modes without changing a line.
src/modes/launchModes.ts) keeps its screens off the home screen and redirects its
routes home. Everything below describes all three, because all three are in the code.
Why it's paused βBuilt with Expo (React Native): write the screens once, ship them as a native iPhone app, a native Android app, and a web export serving the app routes of gamesforgroup.com (/host, /local, /room/β¦, /lan/β¦). Every game and all three play modes live here. This is where new work happens.
gamesforgroup.com is the Expo web export, static-rendered: every route is a real HTML
file with its own title, description and link card (~/site/Seo), so the App Home
is the root and the pages that must be found β one per game (shipped and coming, from
src/games/catalog.ts), contact, privacy, terms β are ordinary app routes.
scripts/build-site.mjs adds the sitemap, an llms.txt for AI
assistants and the host's own rules (redirects, security headers, the not-found page), and
strips the dev-only galleries. Pages stay flat files served at clean addresses
(privacy.html answers /privacy). There is no second content site to keep
in step.
The folders under apps/app/src/ β each with one clear job:
| Folder | Job | Highlights |
|---|---|---|
src/app/ | Screens. One file = one screen (a system called expo-router turns filenames into navigation) | (tabs)/ Home Β· Games Β· Profile Β· local pass-and-play Β· host create a room Β· room/[code] an online room Β· games/[slug] a game's public page Β· lan/β¦ and lan-guest the paused mode |
src/home/ | The App Home (the Crew page, crew/), the Games and Profile tabs' pieces, and the frame the public pages share | The couch hero, "your room is still going" cards, the three ways in, the game stickers, the Crew header and footer, the phone-only bottom bar |
src/site/ | The public pages' frame | Seo (title, description, link card per page), game pages, contact, privacy, terms |
src/modes/ | The three play-mode shells (see below) | local/ Β· online/ Β· lan/, plus the set-up rules they share |
src/games/ | One self-contained folder per game, the registry, and the catalog the site is built from | e.g. tic-tac-toe/ β copy it |
src/ui/ | Shared arcade parts that are actually in use | NeonButton, Modal, PlayShell, PlayersSheet, WinnerScreen, the dice kit, the presenter⦠|
src/i18n/ | Every word a player reads | One English file per area of the product (see "The words" below) |
src/theme/ | The accent colour recipes | colors.ts β every accent style written out literally |
src/device/ | What this phone remembers about its owner | The name and colour it plays as, the id it rejoins with, the look (light or dark) |
src/fx/ Β· src/devtools/ | The animation shelf and the dev-only galleries | Never imported by a product screen; stripped from the website |
A Board is a game's screen. It gets exactly two things:
the current game state to draw, and a dispatch function to report taps.
That's the whole contract (BoardProps). A shell is the
frame around the Board that supplies those two things for a particular mode:
modes/local β a Zustand store (a tiny in-app memory box) runs
the rulebook right on the phonemodes/online β subscribes to Convex; the cloud runs the
rulebookmodes/lan β talks to the host phone over WebSocket; the host
runs the rulebookstate, calls dispatch(action). No
if (online) branches, ever.
game.ts. The rulebook validates moves with the
same helpers, so what a player sees as tappable and what the rules accept
can never disagree.~/ui/presenter holds the snapshot on screen plus the one move playing over it,
queues anything that arrives meanwhile, and adopts the move whole when it lands
(usePresentation is its clock). A game supplies only two answers: which move takes
one state to the next, and how long showing it takes. Dice games also share
~/ui/dice: the 3D cube, the strip under the board (roll line Β· turn pill Β· die,
with the first-roll hint, shake-to-roll and the "who sees the die" rules built in) and the
timing every piece waits on β nothing moves until the die has landed and been read. A big
moment can zoom the board in and follow a piece with useFollowCamera, and
~/ui/board/MascotRim puts every player's mascot peeking over the board's top edge,
the one on the clock climbed up and speaking, a seat ducking or out of the game sunk behind the
rim when its game says so. Snakes & Ladders and Ludo both stand on this kit and keep
only their board art, their sentences and what a roll is; Chain Reaction is the same kit without
dice, replaying an explosion instead of a walk; Word Search uses the rim alone, with no turns at
all: whoever holds the phone is awake where they peek, and a finder climbs up only to shout a word.
ownsTurnStatus), because the shells' card reads the
settled rules. A two-player board that does this (Checkers) draws the same two-mascot stage the
shells give the other duels, under its board, and its mover says what just happened for a beat
before the other player stands up.
What a tap DOES may still be checked against the settled rules: Dots and Boxes takes a line
tapped while the last one is still being drawn, if the rules already allow it, and the presenter
plays it next, so a chain of boxes is tapped out without waiting. Sea Battle goes one step further:
the board is busy only while a shot flies and lands, and what is said about it afterwards (the
shooter's line, a ship still going down) is an echo that the next shot simply ends.
~/ui/reactions gives any screen (a board, later a lobby or
results) a small button that opens eight emojis, and an overlay where everyone's taps float up.
The emojis are Google's Noto 3D pictures bundled with the app, not the phone's emoji font, so
every phone shows the same eight. It rides the room's live
channel, so it exists online only: with no channel the button hides itself, and a board never
asks which mode it is in. The screen decides when the channel is held. Taps are limited to five a
second, and anything that is not one of the eight is ignored. A screen with room shows the row all the time, on a
phone too (Charades' acting screen keeps it at the foot of the screen). Only a screen the row would
cramp hides it behind a mascot the watcher taps: Quick Draw's phone (the drawer in the paper's
corner). Undercover's unmasking keeps the row over its button on every screen.TurnVoice), and a board or strip asks useSharedDevice() instead of
deciding for itself. Anything shown to one viewer and hidden from another (who sees the die)
goes through that one question, so the board still never learns which mode it is in.Modal's stacked option), so
cancelling leaves you back on the list rather than on the board. It offers one choice,
Remove, with what it means printed under it β online, they can knock again with the
code. There is no Ban. A guest turned away at the door sees "Not this time" the moment the
host taps Keep out, and sees it again if they reopen the code from the same browser β the
server reports it as the door's answer for that session. The host gets a five-second toast,
"Kept Asha out" with Undo, and an Undo puts the guest straight back on the waiting screen. A
toast can carry one button like that in every shell.ConfirmDialog, which has no layout options: a short
question, one line on what happens, then two equal buttons with the way out on the left and the
action on the right. A caller only says whether something is lost (destructive),
which makes the action pink, otherwise yellow. On a phone the buttons sit on the sheet itself
rather than in a pinned footer strip, because a question has nothing to scroll.ui/sheetFocus.ts): everything laid down before the sheet is inert while it is
open (the screen behind, and a sheet under a sheet), focus goes into the sheet and back to whatever opened
it, Tab goes round inside it, and Escape closes it unless it is locked. A screen reader hears one dialog
named by its title, with the backdrop as a Close button before it. In the app itself a phone's sheet already
owns the screen, and Android's back button closes it (ui/useBackClose.ts).modes/useStayInRoom.ts. A browser's Back is left alone: Home offers the room
again. A play page that fits the screen does not stretch under a finger either (usePlayScroll),
so a stroke on a canvas never drags the game.BoardBoundary wraps every board: it catches that (or a render bug), says
it in plain words with one button, and keeps the shell around the board standing. The app's
root has a friendly crash screen for anything else.An online room's lobby is a scene to be in (src/ui/playroom PlayRoom): the
living room, or another scene when the host picks one, with everyone in it a whole mascot who can walk about. One shared phone sets up on a screen of its
own instead (src/modes/local/setup, below). The two are drawn differently and decide the same way:
both read the same seat rules and open the same sheets.
| In the room | What it is | Who can do what |
|---|---|---|
| The room's facts | Its name, how many are in, its code, and Share. A scene with walls for them draws them as things in the room, and then only the way back and the "β¦" menu stay over it. In the living room the name is a painted wooden sign on a nail, with a small number in its corner for how many are in, and the code is the app's yellow letter tiles pegged to a string of fairy lights. Sound, Light or dark and Share hang under the sign as the app's own three buttons. A scene without such walls keeps one row over it: the name and count, the code tiles, Share and the menu, with sound and appearance out in the row on a big screen and in the menu on a phone | A tap on the code copies it. Share opens the QR sheet. The host's tap on the name opens the room's form, in place: its name, its size and its scene. The menu's last row is the way out: End room for the host, Leave room for a guest |
| The scene | Where the room waits: the living room, or another scene from the list (a pool party, with a pool to float in, loungers, towels, and a slide that is ridden, never sat on, is one). In the host's form each scene is a small picture drawn from the scene itself, and the one that is on wears a green ring and a tick: a row that slides sideways on a phone, with the next picture peeking, and even lines on a big screen (three across at the most, and never one alone on a line). When the room moves, everyone is simply there in the new scene, at once, seated or in the water where it welcomes them: nobody walks in. Coming in (through the door of the living room, down the slide at the pool) is for one person only, somebody who has just joined the room | The host's tap on a picture moves the room at once, for everyone. A guest never sees the pictures |
| The people | Everyone here, each a whole mascot in their own colour, the host under a crown. Someone whose phone has gone quiet is faded. Names hang under them in a small room; in a big one a tap says who it is | The host's tap on someone also offers Remove, and "Remove Sam?" is asked before it is done (they are out of the room and must knock again) |
| A place | The sofa's seats, its back and arms, the bean bags, the cushions on the rug, standing room behind the sofa. Tap one and your own mascot goes there; tap where you already are and it says hello, or bounces on the sofa. Other scenes have places of their own, and a place can keep a mascot moving for as long as it is there: bobbing in a swim ring, to and fro on a swing, up and down on a trampoline, dancing on a floor. Nothing announces any of it | Each person moves only themself. Every phone sees it |
| A ride | A slide, a pole, a zip line: tap it and your own mascot goes up to its top and down it, cheering on the way, and comes to rest in a place at its foot (or climbs out to the open ground when those are taken). Nobody rests on a ride. A scene can have more than one, each with one rider on the way down at a time: the backyard has a slide into a ball pit and a zip line from the apple tree into the bouncy castle. Whoever has just joined the room comes down the scene's first ride | Each person rides only themself. Every phone sees it |
| Things to tap | The lamp switches off and on, for everyone and for whoever comes in later. The popcorn pops, and the robot vacuum spins and chirps, once, for whoever is watching. Two things in the living room are a second way to a setting: the switch by the door flips this phone between light and dark, and the radio on the shelf turns this phone's sound off and on. The ordinary buttons for both hang under the sign. A few things are only there to be found, on this phone alone: the clock swings, something crosses the window, and in the backyard after dark the owl in the apple tree blinks, fluffs up and hoots | Anyone |
| What the room does by itself | It answers what really happens. A knock: the door rattles and everybody looks at it. Somebody new: everybody looks to where they are 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 phone gone away: its person dozes where they are. Between events the room is never fully still (curtains breathe, clouds drift, dust hangs in the sunbeam), and all of that rests under reduced motion and while nobody is looking | Nobody taps anything |
| The door | The knock: someone peeks out of it in the colour they would wear, and a bubble says so. Whoever is let in walks in through it | The host reads who it is and answers Let in or Keep out (with Undo). A guest reads "Knock knock!" and nothing more |
| The bar | What the room is going to play, in one line: "Ludo" with its votes, "Tournament Β· Chess", "Party Β· 3 games" with about how long, or why Start is waiting. On a phone or a tablet it sits at the foot of the scene and opens the games sheet; on a laptop or desktop it is the foot of the games panel | The host's has the one Start beside it. A guest reads who is picking |
| The games sheet | "What are we playing?" with three tabs. Game: the picked game up on a big screen, playing, the room's votes, and every other game as a small still in groups that say who gets to play. Tournament: the ladder for this many people, and the two-player games. Party: the app's one party builder, with tonight's line-up and the stills of what could go in. A phone or a tablet opens it from the bar, as the phone's bottom sheet (centred on a tablet); a laptop or desktop keeps it open in a side panel a third wide (in the app a tablet is never a desktop; on the web a desktop is a window 1024 wide or more with a mouse or trackpad) | The host's tap picks, and the three kinds replace each other: the room has one plan. A guest's tap is their one vote (on another game it moves, on their own it is taken back), and a guest reads the host's party and can change none of it. A long press, or the "?", opens How to play |
| Start | One yellow button. It waits, with one line saying why, until the plan can start: a game the people here fit, a tournament with three in the room, a party with enough games | The host's only. A game that asks something first opens its set-up sheet; a tournament opens its start sheet; a party starts |
PlayRoom draws and hands taps back, and owns no decision. modes/online/Lobby.tsx feeds it
from the room: who is in and whose phone is away, the door, the votes, and what is going to be played. That last
is useLobbyPlan: one plan, which is one game (useLobbySetup), a tournament
(useTournamentLobby) or a party (usePartyLobby). Each kind keeps its own hook and its
own start, and the plan only says which is in hand and reads the one Start off it. The host's picks, the kind
included, are mirrored to the room so every guest's bar and sheet show the same plan.
A scene is data plus drawings (ui/scene): named places and how many each holds, open ground, the
order it welcomes people in, a door, its rides, things to tap. A scene's rides are a list in the scene's own
order, and each is ridden by itself: its own tap targets, its own one rider on the way down at a time, its
own places to land in. A ride ends in water with a splash, on dry ground with a landing the scene draws
itself, or in a drop into something that gives under whoever lands in it; and it can have something the rider
hangs from on the way down, which runs back to the top once it is let go.
A scene may also say four optional things. Its fittings (Scene.Fittings): the
room's own facts drawn as things in the scene. PlayRoom hands every scene the same plain facts
(RoomFacts: the name, the count, the code, the taps the room takes for them, what is going to be
played, and each Start), and a scene with fittings draws them and hands the taps back, so the header leaves
its row out. Its shade: the soft shadow under everybody on its ground. Its
sky: the colour at its very top, which the strip behind a phone's clock takes. And a toy that
roams: one that moves about, so the scene keeps the tap target on it. A drawing is told when
it is a small still picture (the host's pictures of the scenes), and nothing in it moves.
What everybody turns to look at (a knock, an arrival) is the stage's and every scene's
(ui/scene/gaze.ts).
A place is drawn by art rules of its own, which are not the rules for cards and buttons: two weights of
line, one light with a shadow under everything, one viewpoint, calm walls, details, and loops that rest when
nobody is looking (decisions, 2026-10-13).
There is one list of scenes
(ui/scene/scenes.ts), the living room first, and each is a folder of its own beside the kit
(ui/scene/living is the worked example of a place drawn to those rules, and
ui/scene/pool of water, floats and a ride). Each
scene keeps its own drawings and its own paints in its folder; the outline and the paper every drawn thing
shares are ui/scene/ink.ts. Which one a room is in is the room's own
(rooms.scene, read by Lobby.tsx; none, or one this build does not know, is the living
room), and the host changes it from the room's form (ui/playroom/RoomForm, with
ui/scene/ScenePicker), which the lobby saves through modes/online/RoomEditor. The games sheet is
ui/gamesheet. Looked at without a room on the dev-only /playground-preview.ui/scene/places.ts) from who is in
the room and the moves people have made, in whatever order those reach it, and a phone only ever makes a move
for its own person. A move by a ride says which ride, as its number in the scene's own order, so a ride is
only ever added at the end of a scene's list. One phone's memory of it is useLocalPlaces; in a room,
modes/online/useRoomPlaces puts the room's live channel round
that memory, so a walk across the room is a short message between phones and never a database write. With no
channel a phone still draws everyone where the scene welcomes them, and its own taps still move it on its own
screen. When the host moves the room to another scene, everyone is simply there in it: the welcome order is
read off the roster alone, so every phone draws the same picture at once and nothing has to be said. An
entrance is for somebody coming into the room and for nobody else: the roster gaining a person on the phones
that watched, and on that person's own phone the word of the door they came in by
(modes/online/newcomer.ts). They are drawn coming from the door the moment their own first move
is heard. Nobody comes in on a change of scene, after a game, or on a reload, and a phone that is told where
people are draws them there without a walk. Every phone draws the same picture from the same roster and
places, the crowd on the open ground included: your own mascot keeps the slot the room gives it and is only
drawn over the rows in front, so the "You" under it is never stood on. The small joys (a hop and popping popcorn for a vote on the picked game, a cheer when the pick goes
to a game people voted for) are played by each phone from the votes it already holds
(useRoomJoys).The room is for before and after: a party shows its own screen between its games (below). The hotspot mode keeps its own lobby screens.
| Piece | What it is | Where |
|---|---|---|
| The living room's drawings | Behind and under everybody: the wall and floor, the window with its sky, the clock, the pictures, the shelf of our games, the plant and the door. Among the people, each at its own depth: the light, the lamp, the rug, the sofa, the table with what is picked on it, the bean bags, and on a wide screen an armchair and side walls with furniture. On the walls: the sign, the code on its string of lights, the switch and the radio. Its paints are one small palette by day and at night, and its loops live in one file | ui/scene/living (Ground, Room, Fittings, paint.ts, life.ts) |
| Everybody turns to look | The one moment a roomful answers together: a knock, or somebody coming in. The stage works the moment out and each person plays their own part. Whoever is asleep, in the air, or the one it is about, does not | ui/scene/gaze.ts |
| The bubble | A speech bubble pinned to a point and kept inside its box: over the door for a knock, over a tapped person for their name, and on the awards stage | ui/scene/RoomBubble |
| The door's facts | Who has knocked, the colour each would wear, and the host's two answers. The lobby and the party's after-game screen are handed the same door | ui/playroom/door.ts |
| Whose vote counts | Everyone's but the host's, and who has just voted for the picked game | ui/playroom/votes.ts |
| "The room's people are in front" | The lobby says so while it is the screen in front. Only then does a phone watch who is here and check in quickly, because the lobby is the one screen that fades someone whose phone is away | ui/playroom/PeopleOnScreen |
One job: get the names in fast, pick a game, start. No room to walk into. The screen is tonight's crew, standing there, and the friend who is about to join.
| On the screen | What it does |
|---|---|
| The stage | The next friend, already standing in the colour they will wear, under "Who's playing?" (then "Who's next?"). Their name is typed on the tag under their feet ("Player name"). Return adds them and the cursor stays for the next one; Add does the same for a thumb. A first night opens with the cursor already there. A name somebody already has stays in the tag with the reason under it |
| The crowd | Everyone who is in, each a whole mascot in their own colour with a name tag. Whoever has just arrived pops in and waves. Rows are always even, and the friends get smaller as the table grows: thirty are five rows of six on a phone and three rows of ten on a laptop. The screen opens on the names it had last |
| Fixing a friend | A tap on a friend brings them up to the stage: the tag holds their name to change (Return or Done saves; a name that is refused stays, with the reason), and Remove stands beside them |
| Taking a friend off | At once, with no question. A toast says "Removed Sam" for five seconds with Undo, which puts them back in the same place and the same colour. If the table has filled up or the name has been taken since, Undo says so. The toast leaves with the screen |
| The dock | The picked game as a sticker with its picture playing, its name, who it is for and how long it takes; a tap changes it. With nothing picked, a few games fanned out and "What are we playing?". Party sits beside the yellow button; once tonight's party can start it takes the game's place. A tournament is not offered: it needs a phone each |
| Start | The one yellow button, always the next step: "Pick a game" opens the picker, then "Start" (with the set-up sheet when the game asks something first), or "Start party". A line over it says what a picked game still needs |
| Two layouts | A phone is one column: the stage held at the top, the crowd scrolling under it, the dock across the foot. A big screen keeps the same picture in the middle of the page. With nobody in yet the stage stands in the middle of the screen; the first friend sends it up to the top, once, and the crowd takes the room under it and stands as big as that room allows |
modes/local/SetupScreen holds every decision and setup/Setup only draws it and hands
taps back. The names, their colours and the cap are useTable. Whether the table fits the game, who
sits out, the words on Start and what the set-up sheet asks are the same functions the online lobby calls
(modes/setupRules, useSeatPicks, startSheet), and the game picker, the
set-up sheet and the party's sheet are the same components. So the lobby can change its scene without this screen
following, and neither can answer a seating question differently from the other.The rules of a party are pure and live in the engine (packages/engine/src/programme).
The screens are shared (src/ui/party) and each shell adds only its plumbing: one shared phone
(src/modes/local/party) keeps the party in its own memory, an online room
(src/modes/online/party) reads it from the server.
| Moment | What the table sees | Where it lives |
|---|---|---|
| Building it | Tonight's line-up: tick games, hold a row and drag it, or tap Random and the night is picked for the table (a few of the games on offer in a random order, a different pick every press). Before any game is in, the places for the games it needs stand empty and Random is the wide button under them. Only games that seat everyone here and play in this mode are offered. Under them, the games this many are too few for stand the way the room's sheet shows them, as the dimmed stills of "Not enough of you yet" with what each needs (a tap opens its How to play), on one phone and in a room alike; the ones for smaller groups are named in one line. A ticked game that a friend coming or going has put out is named in one line ("Ludo needs 4 or fewer. It's out."). On one shared phone it is "Tonight's party", a sheet of its own (a bottom sheet, or on a big screen a wide dialog with the two lists side by side) with how many games, about how long and Start party at its foot. In a room it is the Party tab of the games sheet, with the room's stills as the games that could go in and the room's one Start; a guest reads the host's line-up there. The ticks survive a reload: one shared phone keeps them on the device, a room carries its host's | PartyBuilder, the one builder, over usePartyBuilder; PartySheet is its frame on one phone and ui/gamesheet mounts it in a room; what is offered, what is out, the three-to-six rule and the minutes are partyPlan.ts, tested against the real games; the device's memory is partyDevice.ts |
| The running order | In a room, the bar: "Party Β· 3 games" over about how long, or "pick 2 more", beside the host's Start party; every guest's bar says the same, and the Party tab lists the night. On one phone, the set-up screen's game card becomes tonight's party: "Up first", how many games and about how long, Change, and the yellow button reads Start party. When a game going out leaves the night too short, what is left stays up, the game that is out is named, one more is asked for and Start party waits | The room's bar reads the builder's own answers (ui/playroom/planLine); one phone's card is usePartyDraft |
| After a game (and before the first) | The party's own screen, never the room. On the stage, only the winner of the game just played, on one gold step, saying so ("I won Ludo!"); friends who won together stand on that step and say it together; before the first game, after a draw, a game that stopped early or one that could not be played, one friend says the one line there is ("Up first: Ludo!", "Ludo seats 4. Not tonight."). Everyone else stands below as a whole mascot with their name, and nobody is drawn twice. Nothing is counted. Under them, a card: the next game's picture, its name, "Party Β· game 2 of 4" and a kitchen-timer clock counting down eight seconds to it, with everyone hopping on three, two and one. Whoever runs the night has a quiet Pause and the yellow Start now. Pause stops the clock on every phone ("Paused") and the quiet key becomes Resume, which starts a whole countdown. Everyone else reads one line: who can start it sooner, or on a break, who will. A tap on the card opens tonight's party: what was played and who won each, what is next, what is left, the awards. In a room the host answers a knock on a card held over their keys and removes someone from the Players sheet; on one phone a friend is added by name (the countdown waits while it is typed) and taken off at once, with Undo. A big screen puts the stage and the crowd on the left and the next game up large over tonight's party on the right | ui/party/between: AfterGame draws it and hands taps back; facts.ts reads everything it says off the party's own state, the same in both shells; nextUp.ts decides the one line; night.ts decides who is on the stage and who is below. The adapters are modes/online/party/PartyAfterGame and modes/local/party/PartyAfterGame (with modes/local/between/Between for the names). Looked at without a party on the dev-only /between-preview |
| A game | The game as it always is, with "Party Β· game 2 of 4" under its name. Its short version, where it has one. Whoever runs the room has "End party" in the menu. Nobody skips a game: the group picked them. When the game ends its board stays up for a moment before the party's screen says who won | The ordinary play screen; the short version is the game's own rules.party |
| After the last game | The same screen once more, for the last winner, with the awards up next: a cup, "Awards", how many games were played, the countdown, and Start now. No break is offered here | The first step of the end of the night (showSteps.ts, after); one phone counts it itself, a room leaves it to the server |
| The awards | A stage: the curtain, a table of trophies, the coral host, one award at a time (name, a beat of suspense, the winner with the cup and the true reason), then the group photo. It moves on with a tap and by itself: one shared phone keeps that time itself, a room leaves it to the server. In the photo everyone stands over a tag with their name and their award. A small table is half on a bench and half in front; past that it is a crowd, in rows that never overlap (four to a row on a phone), and a crowd too tall for the stage scrolls. From the photo, "Another party" opens the games sheet on the night just played | PartyShow. Where it is (ShowAt) belongs to the shell; the stage and the photo's rows are placed by stageLayout and the words come from awardWords, both tested |
| Nothing got played | A night where no game could be played (none was left that fits the group, or none would start) has no awards. The party's screen says so: one friend says why, the card reads "Nothing got played", and whoever runs the night has the one way back to the room. Rare, now that nobody skips a game | nothingPlayed in nextUp.ts is the one test both shells ask; NothingCard is the card |
| Your own award (a room only) | After the last award each phone shows its own player one card: them in their colour with the cup, the award's name, the true reason. Share makes it the tall picture; Done goes to the photo | OwnAward; the card is one component at two sizes (AwardCard, ownAwardPlan.ts, tested), drawn on the screen and on AwardPicture |
| The picture | The group photo as the tall picture every result becomes, with Share under the photo. A crowd stands in clear rows there too, each over a plaque with their name and their award on up to two lines | PartyPicture, on the share-picture frame; partyPicturePlan.ts places it and plaqueLines.ts fits the words, both tested against every award name |
partyStage.ts, tested), so a reload lands where the room is, on a break or not.
Between games it is the party's own screen with the server's countdown, and the host's Pause, Resume and
Start now only ask the server; a game is the room's ordinary play screen with "Party Β· game 2 of 4"
under its name; a board that has just ended stays up for a moment (the server counts "next up" from the end of it) before
the party's screen says who won. Nobody sees the lobby again until the awards are put away. The awards show draws the people the party kept when it ended and the position the
server holds, so every phone is on the same award, the host's button and the server's own clock both move
it one step, and a friend who leaves mid-show keeps their name on their cup. A phone remembers that its
player has looked at their own card, so a reload at the photo lands on the photo. Once the show is at the
photo a guest's phone holds the end of the night for itself (keepsake.ts): when the host takes
the room back, or closes it, a guest still looking keeps their card and the photo until they say so.partyRun.ts), so a phone that locks mid-game, at "next up" or mid-awards comes back to that moment. The
countdown is a saved time: it waits while a name is being typed, and one the phone slept through starts again
rather than firing. Everyone at the table keeps a colour of their own, which the phone remembers beside the names
(tableRoster.ts): a friend who joined mid-party is the same colour in the room after it and in the
next party. Resume on the way back in starts a stopped clock only when the board is what comes up; when the match
opens on "Pass the phone", the tap that takes the phone starts it (modes/local/resume.ts). The dev-only page /party-preview shows every moment from made-up nights.A tournament is many two-player matches at once, so one phone goes through a string of moments:
the draw, your match, what it came to, the other matches, the next draw, the podium. They live in
src/modes/online/tournament. Everything that decides anything is the shared rules and the server;
these screens only show it.
| Moment | What the phone shows | Where it lives |
|---|---|---|
| Getting in | The host picks a two-player game on the Tournament tab of the room's games sheet, under a ladder drawn for this many people. The room's plan is then a tournament: the bar reads "Tournament Β· Chess" on every phone and Start reads "Start tournament", which opens the start sheet. Until three are in the room it waits and says "Needs 3 or more". The same game picked on the Game tab is an ordinary match, with the set-up sheet asking who the two players are. Which games count is read off the games themselves (online, exactly two seats) | useTournamentLobby.tsx, useLobbyPlan.tsx, rules.ts |
| The start sheet | A card for each format the rules offer this many people, the number of rounds for set rounds, a rough length, the game's own options, Start. One line says every match has its own clock; a game that is played on a clock for the whole game (Chess) asks its own clock question among its options in its place, the same question as on its ordinary sheet without "No clock", opening on the game's own tournament length (three minutes each), and the rough length follows the length picked. That answer is part of the tournament's own pick, so it leaves the ordinary sheet's answer alone. On a phone the sheet's two buttons stay pinned at its foot, in a browser as in the app. That foot is the shared sheet's own (ui/Modal): it stands inside the sheet's edge line, and a control reached with the keyboard is scrolled clear of it. The party's build sheet uses the same foot. Beside the rough length, "Just one match" hands the host to the lobby's ordinary start (its set-up sheet, with who plays). A phone gets the bottom sheet, a big screen the centred dialog | StartSheet.tsx; the offer, the default and the estimate are rules.ts (the number of rounds is counted off a tournament the engine itself draws) |
| The draw | Who plays whom, and the count to the round's start (the server's stored moment). A knockout is a bracket: the first round's pairs slide in, an odd count spins the free pass before naming it, and in later rounds the winners hop into their new places. A league is the round's pairs as rows, and from its second round the table over them. The app's host says what is happening, and names whoever has nobody to play in every round it happens (a free pass, a round sat out); that player's own card says so too. Everyone in the room is on this screen for those seconds | DrawScreen.tsx, Bracket.tsx; what the host says is drawPlan.ts, what goes where is bracketPlan.ts, how big is bracketGeo.ts |
| Your match | The game's own board through the ordinary play screen and shell, told which match by its id. Over it one bar: who you play, the round's name, and a clock each. A match is one game, so nothing between the two names keeps a score. A game on its own total clock (Chess) shows no clock in the bar: its board draws its two clocks where it draws them in any room, and while a player has a first move to make (nobody's time runs before it) their mascot says how long the tournament gives them for it ("First move in 0:45"), in place of the turn line. Under it the round's other matches as two small heads each, and the switch for the room's emoji (off until asked for, then on for the whole tournament). No pause, and no mode chips | MatchBar.tsx; the board is mounted by TournamentRoom.tsx inside ui/MatchFrame |
| Through, or out | The player's own mascot, big, with a line from a bank, one plain line about what is next, and one button to the other matches. A tap on the mascot asks for another line | ResultScreen.tsx; the words are resultPlan.ts and lines.ts |
| A draw | A drawn match is the coin's, at once. The drawn board stays up for a beat, then the coin stands where it stood: each face is one of the two players, and it lands on whoever the programme recorded, a fixed time after the phone heard the result, in every game: the drawn board gives way to the coin when its toss is due, however long that game holds a finished board. Nothing is rolled on a phone. The people watching are told a beat after it is down, never before, and no phone shows the next round's pairs until that has been read | CoinToss.tsx; the timing is coinTimes, coinDue and coinsReadAt in flow.ts |
| The Players sheet | One list on every tournament screen: the whole room, who plays whom, who is through, who plays for third, who is out, who came in late. The host may remove anyone, and anyone may leave: whoever goes hands their match to the other player | rosterPlan.ts, useDrawSheet.ts; handed to the play screen as its draw |
| Who has gone | Someone who leaves drops out of the live roster, but the bracket, the table, the podium and the shared picture still name them. A phone that never saw them (it joined late, or was reloaded) reads their name and colour off the newest finished match they sat in: every match keeps its own copy of its seats. Only someone who left before any match of theirs was played is "Someone" | matchesToRemember in rosterPlan.ts; read in TournamentRoom.tsx |
| Watching | Anyone with no match of their own: through, out, a free pass, came in late | watch/ (the next section), mounted through Watching.tsx |
| The podium | Gold, silver and bronze on their steps in medals, the champion in a crown, one shower of confetti, and the best fan's own prize given by the host (a kind word when nobody backed a winner). The host plays again or goes back to the room; a guest waits; anyone shares the picture. A big screen stands the host, the steps and the best fan in one row | Podium.tsx; the picture is PodiumPicture.tsx on the shared picture frame |
flow.ts, tested). A board is held until its ending has been shown; 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
a beat before the draw or the podium replaces it, and while that beat lasts the watching screen stays on the round that has just ended (roundOnShow). A coin holds the draw back for everyone until it has landed and been read. The room mounts one screen per tournament (keyed by its id):
Play again makes a new tournament whose matches carry the same names, and what a phone has shown of the last
one is never taken for the new one's. The screens are bound to nothing: TournamentFlow
is handed the board, the watching screen, the header and the host's buttons, so the room binds them to the
live room (TournamentRoom) and the dev-only page /tournament-preview binds them to
made-up rooms of 3 to 30 run by the real rules.ui/MatchFrame), asked by the shared pieces a board reaches for and by no game: the mode is fixed
(ModeStrip keeps its round and loses its chips), a line may be handed to the stage
(MascotStage and board/MascotRim say it in place of the turn line: how long a
first move has, in a game on a clock of its own), and a match the room has called (a clock ran out, a
player went) has nobody on the clock, so the stage and the turn card stop saying whose turn it is. A
match is one game, so the sheet between two rounds never opens on a tournament board: the board holds its
last move for a beat and the result follows. The board that outlives the match keeps a leaver's name and
colour from the match's own record (boardRoster). It sits
beside ui/WatchOnly, which is about who is looking at a board.ui/clock/readout.ts): a first move reads between one second and a minute. A turn clock is
not held. Getting the number right on a wrong
clock would need the room's own time on the phone, which nothing sends.In a tournament most people have no match of their own at any moment: they are through and
waiting, out, or came in late. They are all on one screen (src/modes/online/tournament/watch),
and it shows the same things on a phone and on a big screen.
| Part | What it does | Where it lives |
|---|---|---|
| The strip | The round's name ("Round 1", "The final", "Round 3 of 7") and the viewer's fan points. In a league it also holds the way to the table | FanStrip in watchParts.tsx |
| The matches | A phone: one match fills the screen; the pair tabs (the two players' heads, a live dot, a tick on the winner) or a swipe hop to another. A big screen: the round's matches side by side, four to a page (two on a narrower one), and a tap picks the one the emoji go to. A watcher lands on the match they were on, else the final, else the first one still being played | WatchBody.tsx; the choices are watchModel.ts, tested against real tournaments |
| One match | The two players as their mascots, the one line the stage says (whose turn, how long a first move has, who is through), each player's clock beside their name when the game keeps a clock of its own (read off the game's rules, so any game with one gets it), a side to back under each, and the game's own board, watch-only. A match that has just ended shows its ending before its tab is ticked; one the coin settled is shown as undecided until the coin is down on its players' screens. While a board is on its way the stage keeps its room and says nothing | MatchStage.tsx; the board is ui/PlayShell with watchOnly; the holds are useWatching.ts |
| Backing a player | "I'm with Riya" from the draw until picks close (the server's own rule: a time after the match starts, or its end), changeable until then; the heads of the other fans sit on each button; when it ends the line says whether the pick came in. The room's picks on a match are held open only while it is on; the viewer's own picks, their points and how each came out are one small list for the whole tournament | backing, settledPicks and pointsShown in watchModel.ts; picksOpenFor, useMatchPicks and useMyPicks in the programme hooks |
| The emoji | The row sits out under the screen and the emoji rise over the board of the match being looked at. They belong to that match. With no live channel the row is not there | useWatching.ts and the reactions kit (live channel) |
| The table | For the two league formats: every player's head with their wins on its visor, their last three results as dots, and who plays whom this round. Opens from the strip as a sheet | LeagueTable.tsx |
ui/WatchOnly): the shell draws no turn card, the mode chips go, the
round sheet stays shut, and a board's own stage of mascots (MascotStage, MascotRim)
is not drawn, because the screen around it has the two players and tells that story. On a big screen only the
chosen match's board is heard (Quiet in ui/SoundProvider). The dev-only page
/watch-preview plays a made-up tournament through the real screens (?room=CODE opens
the real one over a real room).Every final result can become one tall picture (src/ui/sharePicture). The winner screen
both shells already share carries the Share button, so one phone and a room make the very same picture.
| Step | What happens | Where it lives |
|---|---|---|
| Which picture | Decided from what every phone holds when a game ends: the game's name, the seats, places, scores, a winning side. Two players stand face to face with the score. Up to six get the winner on a gold block with the rest ranked in front. A winning side stands together on a bench. Seven to thirty get a podium over a grid of places. Joint winners all stand together: on the gold block of a small table, crowned in a row or two over the grid of a big one, and as gold cells when there are more than twelve. A match that stopped short has no picture | plan.ts, layouts.ts, layoutBig.ts, tagMetrics.ts: plain numbers, tested for one to thirty players and every number of joint winners |
| Whose names | The room's live list, with anyone who has left since the game ended put back from the match's own copy of its seats, so a result never loses a name. One phone keeps its players with the match already | ~/ui/resultRoster, read by WinnerPanel |
| The words | A line shrinks to fit, then is cut. Widths are the fonts' own for Latin letters and an estimate by kind for other scripts; counting and cutting go by whole characters, so a flag, a family or an accented letter is never split | fitText.ts |
| The drawing | The room's wall and floor, a garland with the game's name, the headline, the brand label, and between them the hero: mascots in seat colours, blocks, name tags. Always the mint look, whatever appearance the player chose. Past twelve players every mascot wears its letters | PictureFrame and the pieces (PicMascot, PicTag, PicBlockβ¦), all placed in the poster's own 1080 by 1920 units |
| The preview | "Your picture": the picture as it will be saved, one button under it. A phone gets a bottom sheet, a big screen a centred dialog | SharePictureSheet over the shared Modal |
| The file | The same drawing again, out of sight at full size, captured as a PNG of exactly 1080 by 1920. A phone captures the view; a browser paints it into a canvas with the page's own fonts. Nothing is uploaded | pictureFile.ts (phone), pictureFile.web.ts (browser) |
| The hand-over | A phone opens the system share sheet, which is also how the picture reaches the camera roll. A browser makes the file as the sheet opens and holds the button until it exists, then shows that very file as the preview. A phone's browser shares it when it can; a laptop downloads it; a browser that can do neither for sure (the ones inside other apps) asks for a press and hold on the picture. The mascot says only what happened, and nothing after a share sheet that was closed. Every share has a file name of its own | the same two files, SharePictureSheet, shareName.ts |
| On the result screen | Share is a small button at the start of the actions row (the arrow alone on a phone, the arrow and the word on a big screen). The winner screen fills its shell and scrolls itself, with the actions pinned under the standings on a phone | WinnerScreen, SharePictureButton |
/share-preview shows every
picture from made-up results, with the real Share button under it, and its last one is a hero built this way.| Mode | Who runs the rules | Where state lives | Nice touch |
|---|---|---|---|
| Same device | The phone itself | A Zustand store on the device | Leave mid-match and the app offers to resume where you left off. What the phone saved is checked as it is read: a match or a party this build cannot pick up again is forgotten, the friends' names and colours are kept, and the set-up screen opens. A match that was read back is on trial until its board has been drawn and its clock has ticked once: a fault before that forgets the match (a party keeps the games it has recorded), and a fault after that deletes nothing. A match on a clock stands still while nobody can be looking at it (the phone put away, the tab hidden, the screen left, the app closed) and starts again on a tap |
| Online | Convex, in the cloud | The database | Every screen subscribes β moves appear on everyone's phone instantly |
| LAN (paused) | The host's phone | The host app's memory | Works with zero internet β see the LAN page and the pause note |
playerView filter, and whoever runs the rules
(cloud or host phone) sends each player only what they're allowed to
see. Same-device mode solves it socially instead: hand-off screens between
turns. That screen is also where a turn BEGINS β a game with a clock names the action it wants
sent the moment the player says they are holding the phone, so the board arrives with the clock
already running rather than behind a second tap.Candy faces on thick dark outlines with a hard shadow underneath, on a plain
stage with no coloured glows: deep navy (Midnight) in dark, the home's mint in light, the player's
choice.
The reference is the Stitch home page in
stitch_games4group_ui_concepts_redesign/; the rules that matter every day:
A button or card is one solid colour, a 3β4px dark outline, and a hard offset shadow (the "pop"). Pressing drops the face onto the rim. No blur shadows, no decorative glow.
Hot pink hosts an online room, party yellow is the primary action and the codes, neon green joins, cyan and violet fill in. The engine's accent ids kept their names, so every game's declared colour still works.
Grounds, cards, text and lines are theme tokens (bg-app,
bg-card-2, text-ink, text-bodyβ¦) with a value per
appearance; accent text deepens on the light ground. The header's round sun/moon button shows
the look on screen and one tap flips it, the way the sound button flips the sound; Profile has the
same flip as a switch. The device always remembers light or dark: the first time the app opens it
takes the phone's or browser's look once, and after that only the player's tap changes it.
Lilita One β headlines, button labels, badges (the home shouts in caps). Fredoka β friendly copy. Rubik β the quieter in-play screens, whose mocks are Rubik. One family name per weight.
A screen asks for a sound by what happened (a tap, a piece put down, a right answer, a countdown), never by how it sounds. The app's little noises are built out of tones the moment they play, so there is nothing to download. The thrown dice is a real recording, because a physical object on a table has a texture tones cannot reach. Both kinds go through the same play call, so the header's sound switch silences either, and a screen never knows which it asked for. One action makes one noise: a sound a game plays for a move is the only sound that move makes, and the shell's whose-turn chime is a connected-mode thing, because on one shared phone the turn changes on the very tap that made the move. The whole of it, with the mascot's voice and the music: Sound.
A screen asks for both through one helper β the noise and the phone's own buzz together β so nothing has to remember one and forget the other. The buzz rides on the shared buttons, so every game gets it without writing a line: a light knock on a press, a tick when a choice lands, a happy double when a game is won, a blunt one when a move is refused. It fires on taps that CHANGE something, never on the frames of an animation, and it is silent on the web and for anyone who has turned haptics off on their phone.
Wherever a game is listed, its picture is a small cartoon drawn in code: mascots in player colours playing
that game. It sits still on its best frame. Exactly one card moves at a time, the one you are looking at:
the one under the mouse on a big screen, the one nearest the middle of the screen as you scroll a phone, or
the single card on a sheet or a game's page. Because it is drawn rather than a picture file, it follows light
and dark, stays sharp at any size and needs no internet. Every game has one; a game cannot be added without
it. A link to a game's page pasted into a chat shows a still photograph of the same scene. A game that is
still coming has no scene yet, so its page shows a poster, and a link to it shows that poster on a dark card. Every card can be seen at the developers' page
/gamecards.
A game's how-to card is the same card everywhere: the sheet behind every "?" and the game's page. On a phone the sheet is one slim row (the game's little picture, its name, its line, its facts), then quiet tabs, Read rules Β· Watch video, small chips for the ways to play, and the video or the steps; the card opens on the video. On a big screen the sheet widens into two columns: the video on the game's own colour on the left, the words and steps on the right, both at once and no tabs. The game's page reads the same way: tabs on a phone, the steps beside the video on a big screen. The video plays right there in the card: its picture with one play button, and a tap plays it in place. A game with several ways to play has a video for each, and the one shown is the one for the chip you picked. The videos are kept on our media address, not inside the app, and nothing is fetched until a video is showing. If a video cannot load, the coral says so where it would be. A game or a way of playing with no video, and the hotspot mode (no internet), have no tabs: only the steps (on a big screen's sheet the game's animated picture stands where the video would).
On the home and on the Games tab, a tap on a game opens that game's own page, on the web and in the phone app. The page is where a game is read about and started: its name and picture, the coral with its one-line pitch, who it is for and how long it takes, and two buttons right there, "Play online with friends" and "Pass one phone around". Each carries the game: the one-phone set-up opens with it picked, and a new online room opens with it already the host's pick (the game still waits for the host's Start, once friends are in). Under them is How to play, the same card as the "?" sheet, and then four more games as the home's own cards. A game that is still coming has a simpler page that says it is being built.
Each player gets one of 8 colors; it follows them everywhere β avatar, board token, roster row, winner banner. Where the colour becomes WRITING it switches to that colour's ink shade for the appearance on screen β a deep shade on the light ground, a pastel on the dark stage β because party yellow as type on white is a name nobody can read.
Touch targets at least 44px. There are exactly two layouts: a phone
below 768px and a big screen from 768px up, tablets and desktops alike
(useBreakpoint().isBig). The home, the Games and Profile tabs and the public
pages stack into a 480px column on phones; on a big screen they share the Crew's page column
(the whole width up to 1280px), the three doors share a row and the game stickers run as
many across as fit. The play screens' frame grows further with the window
(usePageFrame: the full width up to 1280px, then 84% of it, up to 1760px). A game board takes its size from one rule
(useBoardMax): phones get the native column, big screens get the largest board
the column and the window's height allow, so the turn card under it stays on screen and a
2560 monitor gets a bigger board. A board TALLER than it is wide (Chain Reaction's 6×9
grid) hands that rule its own shape and is sized by the height instead, on every screen. The play
screens' scroll is as wide as the screen with the page's gutter inside it, so a board of small squares
(Checkers, Sea Battle) reaches a little into that gutter on a small phone, as far as a finger-sized
square needs and never to the edge (useBoardReach); the pause veil reaches as far as the
board under it does. Sheets are bottom sheets on phones and centred dialogs on
big screens.
Three things can be heard: the little noise a tap or a move makes, the mascot's voice, and music.
All three live in one folder (src/ui/audio) behind the one sound switch in the header.
| Part | What it does | Where it lives |
|---|---|---|
| Sound names | The list of things that can happen: a tap, a piece put down, a piece taken, a chain set off, a right answer, a win, a knock at the door, and so on. A screen only ever says a name | names.ts; a screen plays one through useFeedback() |
| The game sounds | How every name sounds: a few notes built out of tones the moment they play, straight to the speakers, so there is nothing to download. The thrown dice is the one recording | tones.ts, ui/recordedSound.ts |
| The mascot feels | Cheering, a winner's confetti, laughing, being surprised, thinking and waving hello each have a small recorded sound, the mascot's little voice: four recordings, and its own word, "yoo-hoo", is the cheer, the winner's and the hello (a laugh says "woohoo"). It is wired in once, at the one place every mascot plays a reaction, so no screen asks for it. Only the big feelings are voiced: the small things a mascot does while it waits make no sound, and neither does a wave. A mascot waves as it comes up for its turn, as a quizmaster's cue, as a goodbye when a room closes; a hello is its own move (greet, the same wave by another name) and is asked for by that name in three places: at a room's door as you reach it, by somebody in the room as a friend sits down, and by your own mascot when you tap it there. The recordings are fetched once sound is on and the page has had its first tap anywhere, and kept in memory, so a feeling starts as promptly as a tone. A feeling asked for before its recording has arrived (that first tap can itself be a tap on a mascot) is voiced as it arrives, if that is within four tenths of a second, and skipped otherwise. A recording that would not read is asked for again the next time its feeling is wanted | voice/feelings.ts, bank.ts, mascot/voiced.ts, mascot/useMascotLife.ts |
| A tapped mascot | A mascot the person taps is always heard: its own feeling when it answers with a big move, its "yoo-hoo" when it answers with a small one. That holds where mascots are otherwise hushed (the home page), because a tap is the person asking for it. It does not hold on a board that keeps secrets | tapVoiceOf in mascot/voiced.ts, useTapVoice in voice/VoiceContext.tsx, ui/tapVoice.test.ts |
| The mascot talks, in the awards show | A mascot says a line aloud in one place: a party's awards show, where the host opens the show and each winner says their line as they are given their award. Said means babbled: a small blip for each letter, at that player's own pitch (every seat colour has its own note, the coral host has another), while the line appears letter by letter in step. The line is its full size from the first letter, so nothing around it moves. A long line is hurried so it never takes much over two seconds. A device set to less motion hears the babble and gets the whole line at once. Everywhere else a mascot's line is read, never said: a speech bubble is plain text | voice/SpokenText.tsx, used by ui/party/show; voice/babble.ts, voice/pitch.ts |
| One mouth | One voice sound at a time across the whole app. Words win: a line cuts off a feeling that is still sounding. A feeling never interrupts and never queues: if the mouth is busy it is dropped, so a podium of three cheering is one cheer | voice/mouth.ts |
| The mixing desk | The mascot's two voices each have a fixed volume, and the said line gets a little room echo. Nothing squashes the sound afterwards, so the volumes themselves are what keep it clean: they are set low enough that the loudest things that can sound together (two game sounds, the voice and the tune; and an award at its loudest, the win fanfare with a cheer still fading as the winner's line starts over the tune) stay under nine tenths of what a speaker can take. An award comes to about six tenths. Nothing checks this, so a changed volume is judged by ear. The game sounds do not go through the desk, which is how they stay exactly as they were | desk.ts |
| Where it is silent | A sound reaches the whole room, so a mascot is never heard where it could give something away or would never stop. The board of any game that hides something from some players is silent (decided from the game's own rules, so a new such game is silent without being listed). A game then marks, moment by moment, what every screen is shown at the same instant, and those are heard: the rim in Pairs, the stage during Sea Battle's battle, the quizmaster and the reveal in Trivia Clash. Each mark has a test that plays real matches and checks every seat's own view. The home page (its mascots move in a loop), the presenter beside "What are we playing?" (it cheers each card that is opened, on the home and on the Games tab), a winner's dance and every board but the chosen one on a watcher's big screen are silent for whatever a mascot does by itself. A phone's sheet is drawn at the app's root, outside all of these, so every sheet carries in what may be heard where it was opened: a sheet opened by a silent board is silent | KeepsSecrets, PublicMoment and Hush in voice/VoiceContext.tsx; keepsSecrets in ui/PlayShell; games/<id>/voiced.test.ts; useHeardHere and HeardAs in ui/SoundZone.tsx, used by ui/Modal |
| Music, by place | The app has one tune, and it plays at the awards: a party's awards show from the curtain to the group photo (the card a phone shows for its own award included), and a tournament's podium. A screen only says that it is the awards. It never starts or stops the tune. Only the screen in front counts: the app keeps a screen alive under the one opened over it, and a room behind the home page is no place for a tune. One director hears every place that is on screen and says whether the tune plays: it starts as an awards screen comes in front, stops as it leaves, and never plays while the sound switch is off or, on the web, before the first tap. The site's build names the tunes it allows from the app's own list, and refuses to finish with any other music file in it, or with a named one missing | music/director.ts, useMusicPlace in music/MusicProvider.tsx, scripts/build-site.mjs |
| The record deck | Plays the tune when the director says so, fading it in and out in under a second. Every awards show starts it from the top, drum roll first: when the awards leave the screen the tune fades out and its player is let go, so a second party's awards in the same visit do not open in the middle of it. Only the sound going off and on while the same show is on screen (the sound switch, the app behind another for a moment) pauses it where it is and carries on from there. It starts again when it ends, and it is not a seamless loop: it plays to its own ending (two minutes), is quiet for about two and a half seconds, and comes back at its own level, with no fade, so a four-minute show hears it twice and its opening a third time (the numbers are beside the file, assets/music/CREDITS.md; how long that rest should be is left for Karim's ear). Each phone plays its own copy, so several phones in one room are close together and not in step. The file is fetched the first time the tune is wanted, never with the first page: a megabyte and a half that a visitor who never reaches an awards show never downloads. A phone's build has it inside the app, and the hotspot guest page is built without it, because no screen that page can reach is the awards. A tune that will not play (its file would not load) is let go, and a fresh player is made the next time it is wanted; in a browser the deck holds the page's own audio element, which is the one that says when a tune has failed. Music rests while the app is not being looked at | music/deck.ts, music/tunes.ts |
The header's sound switch silences game sounds, voice and music together: the moment it is flipped a mascot stops mid-cheer and the music fades out. On the web nothing at all sounds before the first tap: a browser does not allow it. The first tap anywhere fetches the dice recording (ten kilobytes), sound on or off. The mascot's voice (half a megabyte) is fetched only once sound is on, at that first tap or when the switch is flipped, and the awards tune (a megabyte and a half) only when an awards screen first comes in front with sound on: a page should not spend a visitor's data on a sound they may never hear.
The tune is much quieter than a chime, and that is in the music file itself, because a browser on an
iPhone ignores a page's volume setting. The player's volume is only used to fade it in and out. How loud
each file is, where it came from and on what terms is written beside the files
(assets/music/CREDITS.md, assets/sounds/CREDITS.md).
Game sounds and voice are made with the same audio building blocks on both: the browser's own, and a small library on the phone that copies them. Music and the dice recording use Expo's player on both. Nothing in a game or a screen differs between the two.
Where every sentence a player reads actually lives, and what it takes to add a second language.
No screen holds a sentence. A screen asks for a line of copy by name and gets the words back. The words themselves sit in plain text files β one per area of the product β so the whole app can be read, and reviewed, in one sitting rather than by opening forty components.
| Where | What is in it |
|---|---|
src/i18n/en/home.json | The App Home (the Crew page), the Games and Profile tabs, the tab bar |
src/i18n/en/setup.json | Choosing a game, the players, the start sheet, the door where a host picks words |
src/i18n/en/play.json | The room, playing, passing the phone, the round sheet, the result |
src/i18n/en/common.json | Words that belong to no one screen: buttons, sheets, the how-to card, the switches |
src/i18n/en/errors.json | One line for each way the app can say no |
src/i18n/en/site.json | The public pages: the game pages, contact, privacy, terms |
src/i18n/en/catalog.json | Every game as the website sells it, and the browse categories |
src/games/<id>/copy.en.json | Each game’s own words, in its own folder |
A game keeps its words in its folder for the same reason a game keeps everything else there: two people building two games must never have to edit one shared file. The game’s rules file β which is also the file the cloud runs β carries only the names of its lines, never the lines. That is not tidiness: a server has no idea what language the person at the other end is reading, so it cannot be the thing holding a sentence. The same goes for a refused move. The rules say why in a word the code understands, and the phone turns that into something a friend would say β in that game’s own words when it has some, in the shared ones otherwise.
src/i18n/en/ to src/i18n/fr/, add a copy.fr.json
next to each game’s copy.en.json, and list the code in
src/i18n/language.ts. Nothing else moves, because nothing else holds words. The app
already reads the phone’s own language setting and falls back to English. There is no
language picker and only English ships today β by choice, not by accident
(decisions).Three things stop this quietly rotting. The key a screen asks for is checked when the code is compiled, so a typo or a line nobody wrote stops the build. A lint gate refuses a sentence typed into a screen, so the rule holds by machine rather than by memory. And a check compares the languages against each other β every line present, none empty, the same blanks to fill β so a half-translated language can never ship.
A game may not suit every language. A word hunt deals a grid of A–Z letters, so it is an English game however well the buttons around it are translated. A game can say which languages it works in, and the pickers simply do not offer it to anyone else β better than offering a round nobody at the table can win.
Everything the app says, on one page: docs/copy.html (generated β
rebuild it with bun run copy:doc).
Full list in apps/app/CLAUDE.md. The four that bite:
| Rule | Why |
|---|---|
Styling is uniwind (Tailwind for React Native); accent class strings are written out literally in src/theme/colors.ts | The styler can't see class names built at runtime β dynamic strings silently style nothing |
className does nothing on third-party components (svg, Skia, expo-*) | This once shipped a completely blank app β use inline style there |
| Animations: react-native-reanimated only, kept subtle; dev builds only, never Expo Go | Consistent feel, and native modules (like the LAN server) don't exist in Expo Go |
A web page's first render reads the theme, CSS colours and screen width through ~/ui/hydration and useBreakpoint, never the device directly; and a colour that differs between light and dark is not chosen in script there at all: the page carries both and the browser picks (useThemePick, useThemeColor) | Every public page is HTML built with no browser, in the light look, and the script arrives late: a colour chosen in script stays light until then, which a dark-mode visitor sees as a flash. If the browser's first pass draws something different (a moon where the build drew a sun), React throws the page away. Dev mode never shows it; only the built site does |
PlayRoom: the living room with everyone a whole mascot, one bar for what is picked and Start, and
a games sheet with Game, Tournament and Party. The room has one plan, and its kind is mirrored with the host's
picks. Who is where is agreed between phones over the live channel and stored nowhere. The living room with
its TV stays as the room a party draws between its games. Why:
decisions, 2026-10-12.modes/local/setup). Random picks tonight's games for a table that cannot decide
(partyPlan randomLineup), in a room and on one phone. Why:
decisions, 2026-10-09.setup/designs),
switchable until Karim picks. Taking a name off asks nothing and offers Undo. Why:
decisions, 2026-10-09.modes/local/setup), over
the same seat rules and sheets as the lobby. Why: decisions, 2026-10-09.ui/WatchingMark), and the play shell puts nothing of its own over a board: a board that
fills the phone's height has made room for the header and nothing else above it. The clock bar
(ui/TimerBar) is set going again whenever the match's clock is stopped or started, so it
stands still through a pause and is right after it. Why: decisions and
learnings, 2026-10-06.ui/MatchFrame; a watcher's screen has one pair of mascots
over every game, one small list for its own picks, and names people who have gone from the matches they
played; "Just one match" is back on the start sheet; the lobby's dev start buttons are gone. Why:
decisions.html, 2026-10-04.modes/online/tournament holds the lobby's two ways in (the
stand's "Start tournament" and the wall's poster), the start sheet, the draw and the bracket, the bar and
strip round a player's own board, through and out, the coin, and the podium with its picture to share. They
replace the plain lists the room's branch started with. Which moment is on screen is one pure function over
what is true and how far this phone has got (flow.ts), so nothing jumps ahead of a match's own
ending. The dev-only /tournament-preview shows every moment for rooms of 4, 7, 8 and 30. Why:
decisions, 2026-10-04 (a tournament on a phone). Built overnight, awaiting
Karim's review.modes/online/tournament/watch: a phone shows one match and hops between them by the pair tabs
or a swipe; a big screen shows a page of four. Each match on screen is the game's own board through
ui/PlayShell with watchOnly: the shell then draws the board alone, the mode chips
go and the round sheet stays shut (ui/WatchOnly). Only the matches on screen are subscribed
to. Backing a player, fan points, the room's emoji per match and the league table sit on the same screen;
the dev-only /watch-preview plays a made-up tournament through it. Why:
decisions, 2026-10-04 (watching). Built overnight, awaiting Karim's
review.room/[code] hands over to
modes/online/programme: your own match if you are in one, the matches to watch, the bracket,
the podium, or a party's next game and awards. These screens are plain lists on purpose, waiting for the
approved designs, and each is its own component. The data comes from three small hooks
(useProgramme, useMatch, useFanPick). One match is drawn by the
ordinary online play screen, which moved out of the route into modes/online/PlayScreen so the
room's own match and a tournament match share it, and a player's moves go through
useMatchDispatch, which names the match. Built overnight, awaiting Karim's review.callerVoice.ts). Why:
decisions and learnings, 2026-10-02.claimablePrize, the same check the
reducer refuses a claim with). Why: decisions, 2026-10-02.src/ui/room): the code as a garland, the people on a sofa,
the door as the knock, the TV as the picked game, a remote, popcorn votes, and Start. Each shell is a thin adapter
over it. The room card, the roster cards, the two-column set-up layout and the Start bar are gone. Why:
decisions, 2026-10-02.MascotRim
gained awakeId (a seat awake where it peeks) and climbWave (off for a game that plays its
own feeling on the climb). Why: decisions, 2026-10-01.rules.announce now); each open prize wears the heads of whoever is one call away, which the server
builds into every phone's view; your own mascot peeks over a ticket when it is waiting on one number.
DraggableCaller gained a crown (drawn behind the puck, above it) and an aside
(drawn beside it on the roomier side). Why: decisions, 2026-10-01.PlayerHead gained a surprised face,
PlayerMascot a bow tie, and PendingPlayers is deleted (no consumer left). Why:
decisions, 2026-10-01.SpeechBubble gained tailAt (a tail that
reaches to a speaker anywhere under the bubble). Why: decisions,
2026-10-01.SpeechBubble gained a row-sized xs and a danger
ink, PlayerHead sad and sleepy faces, and PlayerMascot shades. Why:
decisions, 2026-10-01.bow
move, and PlayerMascot a resting hat and a way to face left. Why:
decisions, 2026-10-01.src/ui/PlayerHead.tsx draws a player
as the mascot's head (antennas, no arms, no frame) from the kit's own parts, for guess lines, pills
and seat lists. The mascot gained a pencil to hold and a surface prop, so its antenna
stalks follow the ground under it rather than the theme
(why).src/home/crew/) is now the App Home at /, and the
dev-only /crew preview is gone. A returning host or guest sees their live room
right under the hero; the closing host banner went, because the Host door already offers it.
The Games tab, Profile and the public pages wear the same header, cards, stickers and footer
(the old header, hero, doors, shelf and footer were deleted).src/ui/PartyStage.tsx) is a plain ground now; its
coloured pools went from dark on 2026-09-28 and from light today. The dev-only Crew home dropped
its dot grid the same way.src/ui/mascot is the coral mascot as a puppet of separate drawings that move as
layers, so it stays smooth on phones and can change colour and wear accessories. It is shown at
two dev-only pages kept off the public site: /mascot (every face, outfit and move)
and /crew (the new "Crew" home, beside the current one until it replaces it).global.css moved to a deep navy family, and the game pieces,
the win line, the caller puck and the headline drop now read the outline token instead of a
purple colour typed into the file, so the whole app changes with the tokens.useBoardMax gained a tall kind for a board taller than
it is wide, and a game can say how long its news should wait for its board
(rules.announceDelayMs). See decisions and
learnings.setup
from how many people are seated β a duel, a middle table and a full table each get their own
size β and the room's toasts rise while a game draws its own turn line, so nothing lands on
it.useBreakpoint, useBoardMax,
the setup screens and the site pages: phone below 768px, big screen above. Big screens share one
growing page frame, boards and Quick Draw's canvas fit the width and the height with no fixed
cap, and card grids take as many across as fit. See
decisions.ui/reactions,
shown at /catalog. Online only; the bar hides without a channel.meta.header: 'compact' folds the header's controls into one menu
button on phones (tablets and desktops keep the full row). The header now owns the Players
sheet and the Leave / End game confirmation too: each mode hands it who is in the room and what
leaving does, never a button. The account chip and this menu share one anchored menu
(ui/AnchoredMenu). See decisions.ConfirmDialog lost its split and colour options: equal halves always,
a required one-line note, and a yes/no destructive flag. Neutral buttons lift on a
solid rim like the coloured ones. See karims-taste.html.decisions.html.decisions.html.TurnVoice / useSharedDevice), removing a player on a shared phone through
the Players sheet's adapter with a stacked confirmation, the board safety net, and the rule that
a web page's first render matches the page the build drew. The folder table gained
home, site, i18n and the shelf.src/i18n/en/*.json and, for a game, in its
own copy.en.json; a screen asks for one by name. A rules file carries only names,
because that file also runs in the cloud. English only for now, with the key check, a lint
gate and a per-language check holding the line. See
decisions.ui/PlayHeader) and one set of host controls
(ui/HostPlayControls) instead of three copies; the winner screen's badge, standings
and confetti are candy rather than glow; every board takes its greys from the theme's own ink
(ui/useInk) and its recesses from a new --color-hole token, so a board
exists on the light ground as well as the dark one. The Shopfront moved onto the App's tokens
and the same three faces, and its home page was rebuilt as the Stitch home β header, pitch,
three candy doors, how party play works, the shelf, a closing invitation β which deleted its
1070-line arcade-floor stylesheet.~/ui β SetupLayout, GamePicker, StartBar,
StartSheet β and the rules they both run into src/modes/setupRules.ts
(seat arithmetic, tested) plus src/modes/useSeatPicks.ts (the seat effect all
three shells had copied). The lobby keeps only what is genuinely its own:
online/RoomCard (code, rename in place, share, QR), online/LobbyRoster
(the people, with the door queue at the top of the same card), online/LobbyExit
and online/useLobbySetup. On a phone the layout can put the people and the games
behind two tabs (ui/SegmentedTabs), and every play surface carries the sound and
appearance pair as ui/ScreenControls. rooms.updateDetails now takes a
seat count as well as a title, so the pencil on the room card edits both. The room's QR is a
shareable card drawn once as SVG (ui/qrCardSvg) with the download and share split
per platform (ui/qrShare + .web) β the phone captures the card with
react-native-view-shot and saves it through expo-media-library, both
loaded lazily. New shared atoms: ui/Pill, ui/EditButton. The stacked
toast was PROMOTED off the shelf and is now what ui/Toast renders; the lobby's tab
switch is ui/Tabs, one sliding pill for every platform. See
decisions.html.src/modes/online/RoomScreenState.tsx is the one shell for
every pre-membership state (slots: Mark, Title, Blurb, CodeCallback, footer);
JoinGate.tsx and the route's dead ends render on it. ui/RoomCodeChip
draws the code as tiles. See decisions.html.src/app/host.tsx keeps its three fields on a pink door card;
the form pieces are shared β ui/TextField (new) and ui/Stepper
(now exported from the settings fields). Sign-in is ui/SignInButton for Google
AND Apple (replacing GoogleSignIn); convex/auth.ts registers both
providers. See decisions.html.src/modes/local/PlayingScreen.tsx renders ui/PlayShell
with passAndPlay (third-person banner, hand-over blip, overlay slot); its pure
decisions live in modes/local/match.ts with a table test. The hand-off gate,
turn banner and mute button wear the party language; End game is a ConfirmDialog.
See decisions.html.src/modes/local/setup/ (roster card, game picker, start bar, the start and
sign-in sheets); the shared pieces it opens β player avatar and chip, seat picker, options
fields, questions set-up β are restyled with it. The party stage moved to
src/ui/PartyStage.tsx so any screen can stand on it. Desktops get two
columns (setup/SetupLayout.tsx). The how-to sheet is one shared
src/ui/HowToPlaySheet.tsx for all three shells, and every game's
HowToPlay is now words on the shared HowToSteps card; the back
pill is src/ui/BackButton.tsx. src/ui/Modal.tsx now has two
presentations β a bottom sheet under 768px, a centred dialog above β and a hero
slot; SheetTextInput picks the right input from context. Both presentations
wear the card gradient and have no close icon. The stage's pools
(src/ui/PartyStage.tsx) are circles sized from the stage's measured box. See
decisions.html.(tabs) route group β index, games,
profile β under Expo Router's tab navigator, with our own bar
(src/home/BottomNav.tsx) as its tabBar on phones only; tablets
and desktops keep the single long page. The appearance switch is a light/dark toggle remembered by
src/device/. See decisions.html.src/device/, and a home rebuilt
section for section from the mock: stage, hero, three mode doors with code tiles,
how-it-works, the shelf of candy-bordered cards, a host banner. The old neon tokens and the
home primitives they fed are gone; src/home/ holds the home's sections.
See decisions.html.decisions.html.decisions.html.launchModes.ts' decision, and the page is
one tree with three layouts chosen by media query, so it stays a single prerendered document at
every width. See decisions.html.decisions.html.src/modes/launchModes.ts), which is off β an all-iPhone group cannot
reach a hotspot that has no internet. Nothing was removed, and the home strapline no longer
promises three modes or offline play. See lan-pause.html.src/device/ (it is what the phone knows about its owner,
not something about being online), and one shared name field now fills itself in on every
screen that asks.scripts/merge-site.mjs, assembled per the committed redirect rules). Why: one
address means invite links read gamesforgroup.com/room/CODE, and the signed-in
state is finally visible to the content pages β the browser keeps the login key per address,
so two addresses could never share it. See decisions.html.decisions.html for the why.decisions.html.bun run new-game) generates a new
game folder pre-wired to all the conventions.decisions.html.react-native-audio-api) gives that same code a way to run on the
phone, so the identical sounds now play everywhere. We deliberately switched off
two heavy extras it ships with β a large music-decoding toolkit (FFmpeg) and
background/lock-screen audio β because we only play short in-game sounds and those
extras would bloat the download and add app-store review scrutiny for no benefit.
See decisions.html for the why./host, never the site
root. The play site deliberately bounces its root to the marketing site
(it only owns /local, /host, /room/β¦), but sign-in came back to the bare origin β
so the bounce fired and threw the login away, landing the host on
gamesforgroup.com looking signed out. Cause: makeRedirectUri()
returns the origin with no path. Web now asks for /host
explicitly; the root additionally refuses to bounce while a sign-in is in
progress. Prod SITE_URL also moved to the play domain β it was still
the old apex from before the cutover, and the play app is the only thing that
signs in.bun run scan:worklets now catches the common form of this without a
phone. Also: ease-out curves are written as Easing.bezier(β¦), because
the other spelling silently became linear on the web./catalog,
/fx) got a real section navigator: collapsible groups, and jumping now
works on phones (it measures on the UI thread β the previous approach silently did
nothing on the New Architecture). The gallery also adds its own top inset, since
AppShell's page variant hands the whole screen to its
child and the title sat under the Dynamic Island.useBreakpoint: phone / tablet β₯768 / desktop β₯1080). There are no CSS
media queries in React Native, so screens pick between complete literal class
strings. Home and the lobby gained real desktop layouts; tall screens center
their content on tablet+ instead of pinning the button to the bottom.ownsTurnStatus so shells skip the generic turn banner
(Quick Draw's board already says who's drawing).GamePosterCard gained a
posterSrc override along the way.CLAUDE.md rule "Architecture docs β keep them in
sync". If the code changed and this page didn't, that's a bug β fix the page.