← All docs Games for Group Β· Architecture

Folders & Conventions

Where everything lives, what a game folder looks like inside, and the handful of hard rules that make the codebase safe to change without breaking things you didn't touch.

πŸ—‚οΈ Every game is one self-contained folder. Two lines register it. Shared code is hands-off. Generated code is invisible β€” you never edit it, you never commit it.

The map

One repository holds everything. The green β˜… marks the one place new game code goes; the gray βš™ items are machine-made and off-limits:

games-for-group/
β”œβ”€ apps/
β”‚  β”œβ”€ app/                        the product app (Expo β†’ iPhone + Android + web)
β”‚  β”‚  β”œβ”€ src/app/                 screens β€” one file = one screen (expo-router)
β”‚  β”‚  β”œβ”€ src/games/               β˜… one folder per game + registry.ts Β· pure.ts Β· catalog.ts
β”‚  β”‚  β”œβ”€ src/home/ Β· src/site/    the App Home's sections Β· the public pages' frame
β”‚  β”‚  β”œβ”€ src/modes/               the three play-mode shells: local / online / lan
β”‚  β”‚  β”œβ”€ src/device/              what the phone remembers about its owner (name, colour, id)
β”‚  β”‚  β”œβ”€ src/ui/                  shared parts in use (buttons, sheets, the dice kit, the lobby's scenes…)
β”‚  β”‚  β”œβ”€ src/i18n/                every word a player reads: en/*.json per screen area
β”‚  β”‚  β”œβ”€ src/theme/               the accent colour recipes
β”‚  β”‚  β”œβ”€ src/fx/ Β· src/devtools/  the animation shelf Β· dev-only galleries (never in product screens)
β”‚  β”‚  β”œβ”€ assets/                  brand art, images, recorded sounds and reaction emojis (each with CREDITS.md)
β”‚  β”‚  β”œβ”€ modules/expo-lan-server/ native "phone becomes a server" module
β”‚  β”‚  β”œβ”€ plugins/                 config plugins β€” re-apply native tweaks on rebuild
β”‚  β”‚  β”œβ”€ android/  ios/           βš™ generated β€” never edit, never commit
β”‚  β”‚  └─ guest-dist/              βš™ built browser-guest for LAN (see LAN page)
β”œβ”€ packages/
β”‚  β”œβ”€ engine/                     the rulebook: game contract + shared mechanics
β”‚  └─ match-host/                 the LAN referee (pure logic, no visuals)
β”œβ”€ convex/                        online backend β€” rooms, players, live game state
β”‚  └─ _generated/               βš™ generated
β”œβ”€ scripts/                       build-site.mjs (export β†’ site), deploy-site.sh, the www redirect Worker
β”œβ”€ docs/                          these pages, the runbooks and the two logs (served as docs.gamesforgroup.com)
└─ wrangler.jsonc Β· package.json  root config (site deploy to Cloudflare, workspaces)
β˜… green β€” where you add a game yellow β€” the load-bearing pieces βš™ gray italics β€” machine-generated

Anatomy of a game folder

Open apps/app/src/games/tic-tac-toe/ β€” the canonical example. A new game starts from the generator (bun run new-game <id>), which lays down these small files pre-wired, each with one job:

πŸ“– game.ts β€” the rulebook

What the game is: which moves are legal, what happens after each move, what losing a seat mid-game means, who won β€” plus small helper functions ("selectors") the Board uses to know what's tappable. Pure logic β€” no visuals, no clock, no dice of its own. This exact file runs on phones, in the cloud, and on the LAN host.

🎨 Board.tsx β€” the picture

What the game looks like. It draws the current state and reports taps. It never knows which mode it's running in β€” that's the point.

❓ HowToPlay.tsx β€” the explainer

The learn-it-in-a-minute card shown before a match and over a running one. A game with several modes skips this file: each mode carries its own steps and the shared card draws them.

πŸ’¬ copy.en.json β€” the words

Every sentence this game says, in its own folder. The rulebook carries only the names of its lines, because it also runs in the cloud, which has no idea what language anyone is reading.

βœ… rules.test.ts β€” the proof

Table-driven tests for every rule: legal moves, rejected moves, final rankings. Pure rules make these cheap to write and fast to run.

πŸ”Œ index.ts β€” the plug

Snaps the pieces together into one module the registries can pick up. Some games also carry a content/ folder (Quick Draw's words, Trivia's questions). When that content is a secret, like a question's answer, it is two lists and only one of them is in the app: a room is dealt from the other, which only the server holds (the backend page).

Two things a rulebook may ask the platform for

Most of a rulebook says what the game is. Two optional lines say what it needs from the platform. Both are generic β€” any game can use them, and a game that says nothing is unaffected.

🧠 "Remember this for next round"

A rematch starts the game from scratch, which is right for a board and wrong for content: without a memory, a room playing five rounds of a question game gets the same questions over and over β€” the flaw people notice fastest at a party.

So a rulebook may hand back a small note at the end of a match saying what it wants to remember, and every referee β€” the cloud, the host's phone, and a single shared phone β€” hands that note back to it when the next round starts. The game decides what goes in the note (usually just a list of ids); the platform only carries it.

Two rules make it safe: the note must be small, because it is stored with the match, and it must be capped, because a note that grows every round eventually gets too big to save. A game also has to say what happens when it runs out of unseen content β€” Trivia starts the cycle over rather than refusing to deal, because a repeated question beats a Play button that won't work in front of your friends.

πŸ“ "The host may bring the content"

A game that plays from questions, prompts or word lists can let the host supply them for the room: type a topic and get a set written, or type the set by hand.

The rulebook declares this and does nothing else. The declaration is five plain facts: how few items still make a usable set, how many to ask for, one sentence saying what a single item is in this game's own words, the shape of one item, and one function that answers "is this item valid?".

Everything else belongs to the platform β€” the writing, the checking, the storing, and the host's screens. Crucially, that one validity function is the same one used everywhere a set is judged, so a pack can never be acceptable in one place and rejected in another. A set that arrives mangled is quietly ignored and the game plays its own built-in content, which is always still there.

πŸ” Why these are contract lines and not Trivia code Trivia asked for both first, but neither is about trivia. "Don't repeat what this room has already had" and "let the host bring the content" are wants any content game has, so they live in the shared rulebook contract and the next game gets them for free β€” no backend work, no new screens, no second implementation to keep in step. How to use them β†’

The table β€” a short game played in rounds

A game that is over in a minute is not played once; friends play four or five and count. That whole ritual lives once in the rulebook (the "table"), so a game that wants it writes no plumbing of its own β€” it answers two questions and gets the rest.

🎲 What the table brings

The start sheet's two questions (how many rounds, which mode β€” or Mix, a different mode each round), the mode chips above the board as the host's live override, the pause after a settled round with NEXT ROUND on it, the running tally, and the places at the end: most rounds won, equal tallies sharing.

A game supplies only the two things nobody else can know β€” when its own grid is settled, and what a fresh one looks like β€” plus its own moves. A game whose rounds run on a clock says that too: the table arms the timer as each round opens and hands the game the moment it runs out, so a round can be called by the clock as easily as by a move (Word Search).

πŸͺ‘ Two kinds of seats

A duel is two players facing each other. One of them leaving takes the game with it, so the match stops short with nobody crowned, and each new round swaps who goes first. Every game says out loud which of the two it is β€” there is no default to inherit by accident.

An open table seats two to eight who all play the same round at the same time. Someone leaving simply drops out and the rest play the series to its end; their rounds stay on the record but they can no longer win it. There is no "first player", so nothing rotates. Below two players, it stops short like a duel.

🀝 A round can be shared

When everyone plays at once, two people can finish level. Then they both take that round β€” one round each, nobody second β€” and the sheet says so by name ("Karim and Bea share round 2"). A round nobody took is a draw.

The tally is always rounds, never points, so it stays something you can say out loud. Why rounds and not points β†’

The programme β€” a tournament, or a party

A room does not have to run one match. It can run a programme: a tournament (several two-player matches at the same time, over rounds, ending on a podium) or a party (several different games in a row, ending in awards). Everything that decides anything about either lives in one place in the shared rulebook, packages/engine/src/programme/, under the same law as a game's rules: no clock, no dice of its own, no screen, no backend. The backend calls it for online rooms and the one-phone store calls the party half, so both always agree.

πŸ† A tournament is two questions

"Start one for these players" and "this just happened: what now?". What can happen is a match finishing or a player leaving. The answer is the new state plus the matches to start right now (who plays whom, who opens), or "it is over". Nobody drives the rounds: the next one opens the moment the last match of this one is settled.

Three formats: knockout, everyone plays everyone, and a set number of rounds where similar scores meet. A drawn match is settled by a coin at once, and remembers it was chance. An odd player out gets a free pass by chance. A player who leaves loses the match they are in, is never drawn again and keeps what they earned; leaving is for good, so it is never reported for a phone that only blinked.

A set round is paired exactly: each player gets the nearest score they have not met, as long as everyone below can still be paired fresh, and two players meet twice only when people leaving made it unavoidable. The picker stops at the number of rounds that never needs a rematch, and everyone-plays-everyone is offered up to eight players. Making a round takes about half a thousandth of a second at thirty players.

πŸŽ‰ A party is a running order

It knows which game is on, which is next, and what was passed over and why (it did not fit the number of people in the room, or it would not start). Nobody skips a game: the group picked them. Who fits is decided at the moment a game starts, never when someone comes or goes: a phone that drops for a moment costs nothing, "up next" simply follows the room. The party ends when the list is done, or when a game is due to start and nothing left fits. It keeps every finished game's places, and it is plain data, so one phone can save it, lock, and pick up where it left off.

The awards are worked out from that record alone, in three tiers. One every night can give, from finishing places: most wins, most second places, most last places, a comeback, the same place every time, won one and sank, won the opener, won the closer, most wins in a row, the biggest climb from one game to the next, a better second half, and two rivals who kept finishing next to each other. One from numbers a game chooses to share. And one of things simply true of a player (top half of every game, their best game, never last, played them all), which is what someone with no trophy keeps, and is never a prize for coming last.

An award needs a real standout and says only what happened in every game. The stage is for whoever played, and it is shared out: one award each from what the night earned, then a true thing about whoever still has none, and only then a second for anyone, never a third. The show hands them over in that order too: everybody's first, then the seconds. A true-of-you award is a different award for each friend it is said of, so "Had a moment" can go up for two friends, each with its own reason, but no name goes up more than twice: a friend with another true line takes that one, and a friend with none left keeps their own award without being called up. A real night is two people who were each in two games. A game that stopped short still counts as a game of whoever stayed at it, so "of 4 games" is the same four in every line, and the same games make a night real. The stage has three to seven awards or none at all: a night of one game, or one where no game ran its course, opens on "A quiet one. Everybody gets a cup." and goes to the photo. Everybody who was there has exactly one award of their own. The full list of awards, the promises the stage keeps and why they hold are at the top of programme/awards.ts.

🎲 Chance without a dice cup

Every draw comes from one number given at the start: the pairs, the free pass, the coin, the lot that settles a tie nothing else can. Each kind of draw has its own stream, so the same state draws the same thing on every phone and on the server, and one draw never shifts another.

What the screens show is read from the state each time, never stored: the rounds and their matches, where one player stands, the table, the podium, the best fan. Fan picks are kept beside the tournament, not inside it, so a room full of fans tapping does not all write the one record every match result goes to.

🧩 Where a programme touches a game Two places only. A tournament starts a match with two settings (how many rounds it plays inside itself, and who opens) and reads one final result back. A party reads each game's finishing places, plus any numbers the game shared. No game knows it is in a programme, and no game was changed for one. Why it sits above the games β†’

Adding a game = one folder + two lines + its page

The whole procedure (details: docs/adding-a-game.html):

StepWhatWhy
1Create apps/app/src/games/<your-game>/ with the files above (bun run new-game)The game itself
2One line in apps/app/src/games/registry.tsThe app now knows the game exists
3One line in apps/app/src/games/pure.tsThe online referee now knows the rules β€” its own list is derived from this one, so nothing in convex/ is ever edited
4Its entry in apps/app/src/games/catalog.ts (status available, its copy.en.json in the words list), its card (a small scene drawn in code, src/ui/gamecards/scenes/), and a still of that card for link previews (public/posters/, taken with bun run card:stills)The game gets its picture everywhere it is listed, its page on the website, the sitemap and llms.txt. A test fails if a registered game has no entry, no card or no still
✨ LAN comes free There is no step 4. LAN mode reuses the app's registry automatically (src/modes/lan/serverRegistry.ts strips the visuals off each game and hands the rules to the phone-referee) β€” registering a game once lights it up in all three modes.
⏸️ Three modes exist; two are offered The product ships Same Device and Online. LAN is complete and tested, and a launch flag (apps/app/src/modes/launchModes.ts) keeps it off the home screen. This whole page describes all three, because all three are in the code β€” a new game still registers in two lines and still gets LAN for free. Why it's paused β†’

The purity rule β€” the one law that matters most

⚑ The law Inside packages/engine and every game's game.ts: no clocks, no dice, no network, no React. Rules never call Date.now() or Math.random() β€” the current time comes in as ctx.now and randomness as ctx.rng(), handed in by whoever is running the match.

Why so strict? Because the same rules run in three places (your phone, the cloud, the host's phone), and they must produce identical answers. A rule that peeked at its own clock or rolled its own dice would drift between devices. Injected time and seeded randomness also mean a rematch is just "same rules, new seed" β€” and tests can replay any situation exactly.

🎲 Where the dice come from A match keeps two things: a seed, fixed when it starts, and a count of its moves. Each move opens its own stream of random numbers from those two (engine/rng.ts), so the rules stay pure and a match can always be replayed. The seed is minted outside the rules by whoever runs the match (the server, the host's phone, or the one phone) from that device's strong generator: 256 bits, written as 64 hex characters. Its stream (ChaCha20, in engine/wideRng.ts) cannot be worked back to the seed from the numbers a player is shown, which is what keeps a dealt secret a secret. A tournament and a party have a seed of their own, the same kind, for their own draws (who meets whom, the free pass, the coin, the lot between equals): each draw opens its own stream, named by what it is for and its round. A plain number is the older, small kind of seed: it still draws exactly what it always drew, for matches and programmes started with one, and for tests. Nothing else is ever read as a seed: the rules refuse text that is not 64 hex characters and a number that is not a whole one from 0 to 4294967295, and a move count that is not a whole number, rather than draw from something weaker or from another move's stream.
πŸ“¦ Import rule that enforces it game.ts imports the rulebook as @g4g/engine (the shared package) and nothing app-internal. Board.tsx, index.ts and tests may use the app alias ~/* freely β€” they never leave the app.

Hands-off zones

When you're adding or changing a game, these are read-only. If one of them is missing something you need, that's a design conversation β€” raise it, don't reach in:

ZoneWhy it's protected
packages/engine/Every game and every mode depends on it β€” a casual change ripples everywhere
src/ui/ Β· src/theme/Shared look & feel β€” one game must not restyle all games
src/modes/The three shells are game-agnostic on purpose
convex/Games add zero backend code β€” the backend is generic
Other src/games/<id>/ foldersSelf-contained means self-contained
package.json dependenciesA game never adds libraries

Generated code β€” edit the recipe, not the cake

Several folders are outputs, rebuilt from scratch by tools. Editing them feels like it works β€” until the next rebuild silently erases your change. This has caused real bugs, which is why the rule is strict:

Generated thingRebuilt byChange it via…
apps/app/android/ & ios/expo prebuildA config plugin in apps/app/plugins/ (or a module's app.plugin.js) β€” it re-applies your tweak on every rebuild
convex/_generated/Convex dev/deployNothing β€” it mirrors your real functions
Version numbersβ€”apps/app/app.json (ios.buildNumber, android.versionCode) β€” never the generated Info.plist / build.gradle, which get reset
⚠️ Real examples of the trap Android release signing and the iOS Podfile patch both live as config plugins (plugins/withAndroidUploadSigning.js, the LAN module's plugin) precisely because hand-edits to android/ and ios/ kept getting wiped by rebuilds.

Everyday conventions

ConventionRule
File namesReact components PascalCase.tsx Β· everything else lowercase.ts
ExportsNamed exports everywhere β€” no default
File sizeSmall, single-purpose files β€” ~150 lines as a guide
TypesTypeScript strict β€” no any escape hatches
Package managerbun for everything (never npm / yarn)
TestsVitest; every game ships rules.test.ts next to its rules
WordsNo sentence in a screen or a rulebook β€” copy lives in src/i18n/en/*.json and each game's copy.en.json
Daily commandsbun run test Β· bun run typecheck Β· bun run lint:copy Β· bun run i18n:check (from apps/app; the repo root runs the engine, match-host and Convex suites)

Decision log

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