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.
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)
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:
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.
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.
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.
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.
Table-driven tests for every rule: legal moves, rejected moves, final rankings. Pure rules make these cheap to write and fast to run.
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).
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.
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.
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.
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.
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).
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.
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 β
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.
"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.
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.
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.
The whole procedure (details: docs/adding-a-game.html):
| Step | What | Why |
|---|---|---|
| 1 | Create apps/app/src/games/<your-game>/ with the files above (bun run new-game) | The game itself |
| 2 | One line in apps/app/src/games/registry.ts | The app now knows the game exists |
| 3 | One line in apps/app/src/games/pure.ts | The online referee now knows the rules β its own list is derived from this one, so nothing in convex/ is ever edited |
| 4 | Its 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 |
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.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 β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.
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.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.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:
| Zone | Why 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>/ folders | Self-contained means self-contained |
package.json dependencies | A game never adds libraries |
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 thing | Rebuilt by | Change it via⦠|
|---|---|---|
apps/app/android/ & ios/ | expo prebuild | A 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/deploy | Nothing β 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 |
plugins/withAndroidUploadSigning.js, the LAN module's plugin) precisely
because hand-edits to android/ and ios/ kept getting wiped
by rebuilds.| Convention | Rule |
|---|---|
| File names | React components PascalCase.tsx Β· everything else lowercase.ts |
| Exports | Named exports everywhere β no default |
| File size | Small, single-purpose files β ~150 lines as a guide |
| Types | TypeScript strict β no any escape hatches |
| Package manager | bun for everything (never npm / yarn) |
| Tests | Vitest; every game ships rules.test.ts next to its rules |
| Words | No sentence in a screen or a rulebook β copy lives in src/i18n/en/*.json and each game's copy.en.json |
| Daily commands | bun 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) |
src/ui/room/ is gone. The old living
room with its TV was drawn by nothing, so it is deleted. The living room's drawings and paints live in
src/ui/scene/living/, the shared outline and the bubble in src/ui/scene/, and the
door's facts, the vote rules and the "people are in front" counter in src/ui/playroom/. Why:
decisions, 2026-10-13.wideRng.ts) and still take a number, which draws what it always drew. Games change
nothing. Why: decisions, 2026-10-04.public/posters/: its
upright poster for its page, and a link card cut from it at the size every page promises. The test and the site
build both open each link picture and read its size.assets/posters/) are gone and a game cannot be registered without a scene. The website keeps one
picture file per game in public/posters/: a still of the card for a shipped game, the old poster for
a coming one.src/ui/room/ is the lobby: one living-room scene both
host shells draw. Its seat layout and its small decisions are pure files with tests beside them.src/ui/gamecards/scenes/. The poster stands in until it exists.assets/reactions/ holds the eight reaction
emojis (Google Noto 3D pictures) with their licence notice in CREDITS.md.src/home,
src/site, src/i18n, the shelf, assets/ and scripts/
are on the map; a game folder carries its own copy.en.json; adding a game lists the
catalog entry the website check already requires.netlify.toml, no Netlify deploy script. The site ships only
with bun run deploy.bun run deploy. The build now writes the
site's own routing and security rules into the folder it ships. The domain moves over once the
nameservers change (why).decisions.html.convex/. It used to be a second hand-written list, and a game missing from it
appeared in the picker and then failed with "unknown game" in front of the host's
friends.apps/app/src/modes/launchModes.ts). No folder moved and no
file was deleted β registration is still two lines and LAN still comes free.
See the pause note.src/device/ β the
session id and the remembered player name. It sat inside the online mode folder, which is why
the LAN files were importing from "online" to ask what this phone is called.apps/marketing/ is the
SEO content site β a permanent part of the map, not the "older web app"
waiting to be retired. See decisions.html.bun run new-game instead of hand-copying tic-tac-toe; shared test
helpers live at @g4g/engine/testing.app.json β they used to live in generated files, where a rebuild
silently reset them (and stores reject a build whose number didn't go up).withAndroidUploadSigning) β the canonical example of "edit the
recipe, not the cake". Secrets stay outside the repo.packages/engine, the old web app to apps/marketing,
and the new Expo app was born at apps/app.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.