The tracks ship independently. Know which one you're touching.
| What | Where | How |
|---|---|---|
| The site (content pages + web play) | Cloudflare Worker games-for-group at gamesforgroup.com (www redirects to it) | Manual — bun run deploy |
| Backend | Convex clean-poodle-980 | Manual — only if convex/, a bundled game.ts or a game's content/online.server.ts changed, and before the site |
| iOS app | TestFlight → App Store | Local build ~15 min (free), or EAS cloud |
| Android app | Play internal testing | Local build ~10 min (free), or EAS cloud |
| Docs site | Cloudflare Worker g4g-docs | Manual — bun run deploy:docs |
The LAN guest client is the thing most likely to go missing, and it goes missing silently. It is the web page browser guests load from the host's phone; without it they get a blank page while the app itself looks perfect. It is a gitignored build artifact, so a build's working copy starts without it. Every build path now regenerates it (aneas-build-post-installhook) and refuses to upload an artifact that does not contain it (eas-build-pre-upload-artifacts→scripts/assert-guest-bundle.mjs). A rawxcodebuildarchive runs neither, so if you go that route, runbun run export:lan-guestand thenpod installby hand — iOS mirrors the export at pod install time.
The Profile tab's small print carries two ungated rows — Components and
Shelf — that open the dev-only galleries (/catalog, /fx). They exist so
Karim and invited TestFlight testers can reach the design system from the app itself, and they
are shown to everyone on purpose while the only builds in circulation are internal. (The
same two links in the home footer are already dev-builds-only.)
Delete those two LinkRows in apps/app/src/app/(tabs)/profile.tsx
before the first release to the public App Store or Play listing. Nothing gates them, so
nothing else will stop them shipping.
The published website never serves the galleries:scripts/build-site.mjsdeletescatalog.htmlandfx.htmlfrom the web build and fails loudly if one survives. On the site those two Profile rows are therefore dead ends — one more reason to delete them.
bun run deploy
It builds, checks, and uploads to Cloudflare. The site is a Cloudflare Worker with
static assets only (no Worker script), named games-for-group and configured by
the committed wrangler.jsonc, which attaches gamesforgroup.com as its
custom domain. The Worker also answers at
games-for-group.games-for-group.workers.dev
(handy for a check that skips DNS; sign-in does not work there).
www: www.gamesforgroup.com is a second, tiny Worker
(g4g-www-redirect, scripts/www-redirect.mjs) that answers every request
with a 301 to the same path on the apex. It is its own Worker because the site Worker is static
files only, and because Google sign-in only works on the apex: a sign-in started on
www could never finish. It almost never changes; redeploy it with
bun run deploy:www.
| Step | What happens |
|---|---|
| Build | The root bun run build: the Expo web export (static — one HTML file per route, each with its own title and description from ~/site/Seo), then scripts/build-site.mjs turns it into dist-site/. |
| Host rules | build-site.mjs writes _redirects (a room code serves the room page), _headers (security headers, a year of caching for hashed files) and 404.html into the output. The folder that ships carries its own rules. |
| Guards | The build fails on a missing page, a home page that hides from search, the dev Convex URL in the bundle, or missing host rules. The deploy script then refuses to upload unless the prod Convex URL is in the bundle. |
| Upload | bunx wrangler deploy — only changed files go up. Needs bunx wrangler login once on the machine. |
URLs: pages are flat files (privacy.html), served at the clean
address /privacy. /privacy.html and /privacy/ redirect
(307) to /privacy. Any unknown path gets the app's not-found page with a real 404.
No automatic deploy. A push builds nothing on Cloudflare. Ship at the end of the day with the one command, the same moment as the TestFlight build.
To check the site locally before deploying:
bun run build
bun run preview # serves dist-site/ with the room rule honored
bun run check:hydration # opens every way in headlessly, fails if a built page is thrown away
bun run deploy runs that last check itself and refuses to upload when it fails. It loads the
home, the games list, a game page, host, same-device, profile, contact, privacy, terms, a room link and a
missing page, as a light desktop visitor and a dark phone visitor, and fails on any error a page throws while
it takes over the built HTML. It needs a headless browser on this Mac
(bunx playwright-core install chromium-headless-shell, once; or name one in CHROME).
Then check Google sign-in, every time the web bundle shipped: open /host, sign in,
and confirm you land back on /host with the account chip showing.
SITE_URL on prod Convex must be https://gamesforgroup.com
(bunx convex env get SITE_URL --prod). Sign-in only works on that domain, so it
cannot be checked on the workers.dev address.
Visit counts: Cloudflare Web Analytics, cookie-free. build-site.mjs
adds the beacon script to every page of the shipped site (never Metro, dev exports, the LAN bundle, or
bun run preview), and the CSP allows its two hosts. The build fails if any page carries it
other than exactly once. Numbers: Cloudflare → Analytics & Logs → Web Analytics.
The domain is registered at Namecheap with its nameservers pointed at
Cloudflare, which holds the DNS zone. Each Worker attaches its own hostname as a
custom domain in its wrangler config — gamesforgroup.com → games-for-group,
docs.gamesforgroup.com → g4g-docs, www.gamesforgroup.com →
g4g-www-redirect — so there are no DNS records to edit by hand. The machine's wrangler
login (OAuth) is enough to deploy and attach domains. If Cloudflare ever refuses, create an API
token with Workers Scripts: Edit, Zone: Read and DNS: Edit on
gamesforgroup.com.
convex/ changed# did anything under convex/, or any game's rules, change since the last backend deploy?
git diff --name-only origin/main | grep -E '^convex/|^packages/engine/|/games/[^/]+/game\.ts$|\.server\.ts$'
Nothing? Skip this. Otherwise:
bunx convex deploy -y
⚠️ Deploy this BEFOREbun run deploy(and before a store build) ships client code that calls a new/changed function, or the live site calls functions that don't exist yet. Everygame.tsis bundled into the backend too, so a rules change needs this even whenconvex/itself is untouched.
A game whose content is a secret has two lists (adding a game, "Content that is a secret"). They ship by different roads:
| What changed | What ships it |
|---|---|
A room list (content/online.server.ts) | The backend deploy, and nothing else. No site deploy, no app release. |
A one-phone list (content/local.ts) | The site deploy and the next app builds. |
⚠️ An item never changes sides. A list only grows, on its own side. If a
row must ever move from a room list into the app, deploy the backend that has stopped dealing it BEFORE the
site and before any app build that ships it: the other order publishes an answer rooms are still being
dealt. A row moved from the app into a room list is a row every installed app already holds, so it protects
nothing. bun run split:decks says when a run would move a row either way.
The first backend deploy that carries these lists changes what a Gibberish, Undercover or Quick Draw match keeps,
so a match of one of them that was started before it cannot be finished after it. Deploy it when no room is
mid-game. The site build checks the web export for the room lists (their seal, and each question's and card's
own words) and refuses to finish if one is there. It does not check a phone build: there the bundler's refusal
to import a *.server.ts file is the guard.
Each game's how-to-play video and its picture are plain files in a Cloudflare R2 bucket,
g4g-media, served at https://media.gamesforgroup.com. The app and the
site only point at them, so putting a video up needs no site deploy and no phone build. Downloads from the
bucket are not billed.
# from ~/Desktop/g4g-marketing/sources (the marketing repo)
tools/publish-how-to.sh reversi # one game: cut the phone-sized file and the picture, upload both
tools/publish-how-to.sh # every finished video
VERSION=v2 tools/publish-how-to.sh chess # a RE-MADE video goes under a new version
Every file is cached for a year, so an address never changes what it holds: a re-made video goes up under
the next version, and apps/app/src/games/howToVideos.ts names the version the app asks for (that
change ships with the site and the next phone build). A new game is added to the list in the same file.
Check one from outside:
curl -I https://media.gamesforgroup.com/how-to-play/v1/en/reversi.mp4 # 200, video/mp4, accept-ranges: bytes
live/ changedThe relay that passes short-lived room messages (the growing line in Quick Draw). Its own Cloudflare Worker, separate from the site. The game works without it, so a relay deploy can never break play; it only brings the live line back.
The address is wss://live.gamesforgroup.com. It is already wired:
the Worker claims live.gamesforgroup.com as its custom domain (live/wrangler.jsonc),
release builds read EXPO_PUBLIC_LIVE_URL=wss://live.gamesforgroup.com from
apps/app/.env.production (the same file that carries the prod Convex URL), and the site's
security headers allow it.
First time only — one shared secret, the same value in two places:
# make the secret into a file only you can read (never echo it, never commit it)
umask 077 && printf 'LIVE_SECRET=%s\n' "$(openssl rand -hex 32)" > /tmp/live.env
# 1. the production backend: the secret, and where to send a removed player's kick
bunx convex env set --prod --from-file /tmp/live.env
bunx convex env set LIVE_HTTP_URL https://live.gamesforgroup.com --prod
# 2. the relay: the first upload carries the secret with it, then the file goes
bunx convex deploy -y
(cd live && bun run typecheck && bun run test)
bunx wrangler deploy --config live/wrangler.jsonc --secrets-file /tmp/live.env
rm /tmp/live.env
# check both sides hold it (names only)
bunx wrangler secret list --config live/wrangler.jsonc
bunx convex env list --prod | cut -d= -f1
The relay's first upload has to carry the secret itself.
wrangler secret put only works on a Worker that already exists, and
live/wrangler.jsonc marks the secret required, so a plain first deploy is refused.
Rotating the secret later: bunx wrangler secret put LIVE_SECRET --config live/wrangler.jsonc
and bunx convex env set LIVE_SECRET --prod --force with the same value piped in.
Then, in this order, and on every relay change:
# 1. backend first, if convex/live.ts, convex/lib/liveKick.ts or live/src/ticket.ts changed
bunx convex deploy -y
# 2. the relay
bun run deploy:live
# 3. check the address answers: expect 426 "expected a websocket".
# 503 "relay not configured" means LIVE_SECRET is missing or too short.
curl -i https://live.gamesforgroup.com/room/abcdefgh12
# 4. the site and apps, which point at it
bun run deploy
deploy:live typechecks and tests the relay, then uploads it and attaches
live.gamesforgroup.com (Cloudflare creates the DNS record and certificate; the first one
can take a few minutes). It refuses to upload while LIVE_SECRET is unset on the Worker,
and names the missing secret (live/wrangler.jsonc declares it required).
A relay deploy closes every open socket: Cloudflare restarts the room objects. Phones
reconnect quietly with a fresh pass, so nobody sees an error, but the live line pauses for a moment
in every room that is drawing. scripts/build-site.mjs refuses a web build whose bundle
points at a relay on localhost.
⚠️live/src/ticket.tsships twice. Convex bundles it to sign passes and the relay bundles it to check them. A change to it needs BOTHbunx convex deploy -yandbun run deploy:live, or passes stop matching and the live line goes quiet everywhere, with no error.
Without the secret on both sides, or withoutEXPO_PUBLIC_LIVE_URLin the build, there is simply no live line: drawings appear a stroke at a time and nothing errors. WithoutLIVE_HTTP_URLon the backend, a removed player is not kicked off the relay at once, only refused at their next connect. Locally:bun run live:devwithLIVE_SECRETinlive/.dev.varsand the same value in the dev backend (bunx convex env set LIVE_SECRET …). The dev backend has noLIVE_HTTP_URL: it runs in the cloud and cannot reach a relay on localhost.
The backend limits how often things happen and how big they are in its own code (decisions, 2026-10-08). It cannot stop a call from being billed, raise its own plan, or cap its own bill: those are settings on the owner's accounts, and no deploy touches them. Each row is one setting, where it is and what to put there. The numbers are starting values, to be raised once real traffic shows what a busy day is.
| Setting | Where | Value | Why |
|---|---|---|---|
| Convex plan | Convex dashboard → team settings → Billing | Professional, before any promotion | The free plan is 1,000,000 function calls a month and 1,000 people connected at once. One script reading every room code uses most of a month. Professional is 25,000,000 and 10,000. |
| Spending limits | The same Billing page, "Spending limits" | Warning $20, disable $100 (over the plan's own price) | The warning is an email. The disable threshold turns every project of the team off until it is raised: an outage, chosen over an open-ended bill. |
| Usage limits, function calls | Convex dashboard → production deployment (clean-poodle-980) → Settings → Usage limits | Daily: warning 1,000,000, disable 15,000,000 | The warning says a day is unusual while it is still cheap. The disable threshold stops the deployment for the rest of that UTC day, at about $30 of calls. |
| Usage limits, data egress | The same Usage limits page | Daily: warning 5 GB, disable 50 GB | Every move is sent to every screen in its room, so a room of made-up players moved by a script is paid for in data sent out. |
| The model's own budget | The Gemini and DeepSeek consoles, on each API key | A monthly budget on each (where exactly is not checked yet) | The backend allows five thousand sets a day in all. A budget at the provider is the brake that still works if that number is ever wrong. |
| Workers plan | Cloudflare dashboard → Workers & Pages → Plans | Workers Paid ($5 a month), at launch | The free plan is 100,000 Worker requests a day for the whole account, and a refused relay connection still counts. A busy Quick Draw evening could use that with nobody attacking. The site's pages are static files and are not counted. |
| Rate limiting rule | Cloudflare dashboard → the gamesforgroup.com zone → Security → WAF → Rate limiting rules (the free plan has one) | When the URI path starts with /room/: 300 requests per 10 seconds from one IP, block for 10 seconds | Covers the relay's sockets and the room pages. A whole party shares one Wi-Fi address, so the number is far above thirty phones joining at once. |
Cloudflare protects the site and the relay only. Phones talk to the backend at its own
convex.cloud address, so nothing set in Cloudflare stands in front of it; the two Convex rows above
are its only brake on cost.
# names only: AUTH_ALLOW_ANONYMOUS must NOT be in the list (it lets anyone sign in without Google or Apple)
bunx convex env list --prod | cut -d= -f1
| To | Do |
|---|---|
| Stop every question set being written, at once | bunx convex env set AI_PACKS_ENABLED 0 --prod. Back on: set it to 1. No deploy, no app release. |
| Stop one account hosting | Convex dashboard → production → Functions → users:block → Run, with { "userId": "…" }. The id is the hostUserId on the room in the rooms table, or the userId on its lines in aiUsage. It closes the account's open room too. Run the function rather than editing the field by hand, which would leave the room running. Undo with users:unblock. |
Two ways to build — both land in the same TestFlight group:
| Path A — this Mac | Path B — EAS cloud | |
|---|---|---|
| Cost | Free | One EAS build credit |
| Your Mac | Busy ~20 min | Free — build runs on Expo's servers |
| LAN export | Automatic (build hook) | Automatic (build hook) |
| Build number | app.json — one counter, in git, for both | |
Use Path A while credits are scarce; Path B when you have credits or a pro plan.
One command does the whole thing, including handing the build to the testers:
cd apps/app && ./scripts/build-local.sh
Two things it guarantees, both learned from build 25 (2026-09-10):
xcrun altool and the App Store Connect key (seconds), because
EAS Submit's queue held builds 25 and 26 for 45 and 60+ minutes before Apple saw them, and
its own wait once hung for four hours so the testers never got the build. build-local.sh then runs
scripts/attach-testflight-group.mjs whatever the upload's exit code; that
script polls App Store Connect for this build number (up to twenty minutes) and
attaches the beta group, or fails loudly. If a build shows in App Store Connect with no
group, run it by hand: node scripts/attach-testflight-group.mjs <build>.expo-build-properties sets ios.usePrecompiledModules:
false (a project property the generated Podfile reads). Build 25 shipped precompiled
ExpoMediaLibrary/ExpoFont/… frameworks built against a different
expo-modules-core than the one in the archive; the app aborted at launch with
"Symbol missing", and App Store Connect mailed ITMS-90863 listing the same symbols. Compiled
from source, one core, one ABI — the whole class is gone, for a few minutes of build time.
React Native's own core stays prebuilt (fast). Keep every Expo package on the version
npx expo install --check expects; drift is how the mismatch crept in.The rest of this section is what that script runs, for when something breaks and you need to drive it by hand.
One command. It builds with eas build --local (no credits) using the shared
Apple Distribution certificate in ~/Desktop/ios-local-builds:
~/Desktop/ios-local-builds/bin/build-app.sh apps/app
Add --no-submit to stop at the .ipa and check it yourself first
(recommended — see the two checks below). Submit afterwards with
eas submit --platform ios --profile production --path build-local/app.ipa.
The build number comes from app.json and
autoIncrement raises it for you. It must end up higher than anything Apple already
has — asc builds list --app 6790182940 — and Apple, not EAS, is the authority on
that. (EAS's own server-side counter used to be the source; it drifted to 8 while Apple had 10,
because local archives upload straight to Apple and EAS never hears about it.)
Two things are invisible until someone is standing in a room with no internet, so check them in the artifact rather than in the repo:
cd apps/app
# 1) the browser-guest client is inside the app (the build also gates on this)
unzip -l build-local/app.ipa | grep -c "guest-dist/"
# 2) the local-network permission survived — a dev-tool build phase strips it
# when the description is not ours
unzip -p build-local/app.ipa "Payload/*.app/Info.plist" | \
plutil -extract NSLocalNetworkUsageDescription raw -
# 3) Expo's modules are compiled IN — no framework in the archive still needs the core
unzip -q build-local/app.ipa -d /tmp/ipa && for f in /tmp/ipa/Payload/*.app/Frameworks/*.framework; do
nm -u "$f/$(basename "$f" .framework)" | grep -c ExpoModulesCore; done
(3) must print only zeros: a non-zero count means a precompiled Expo framework slipped back in, which is exactly how build 25 died at launch.
(2) must print our own sentence about hosting offline games. Empty or missing means iOS will deny local networking with no prompt, and every in-app guest will hang forever.
xcodebuild insteadNone of the build hooks run, so do it by hand first — bun run export:lan-guest,
then cd ios && pod install (iOS mirrors the export at pod-install time, so
archiving without re-podding ships the PREVIOUS one), and set
CFBundleVersion in the generated ios/GamesforGroup/Info.plist as well as
app.json. Then check the archive's copy MATCHES the fresh export:
The count grows as the app does, so don't compare it to a fixed number:
cd apps/app
diff <(find modules/expo-lan-server/guest-dist -type f -exec basename {} \; | sort) \
<(find ~/Desktop/g4g.xcarchive/Products/Applications/GamesforGroup.app/guest-dist \
-type f -exec basename {} \; | sort) && echo "MATCH"
No MATCH (or an empty archive copy)? The export did not reach the app — re-export and re-pod before archiving again.
Save this as ~/Desktop/ExportOptions.plist:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>method</key><string>app-store-connect</string>
<key>teamID</key><string>AL7Y7C2959</string>
<key>signingStyle</key><string>manual</string>
<key>provisioningProfiles</key>
<dict><key>com.karimdev.gamesforgroup</key><string>g4g AppStore local</string></dict>
<key>uploadSymbols</key><true/>
</dict>
</plist>
xcodebuild -exportArchive -archivePath ~/Desktop/g4g.xcarchive \
-exportPath ~/Desktop/g4g-ipa -exportOptionsPlist ~/Desktop/ExportOptions.plist
asc publish testflight --app 6790182940 \
--ipa ~/Desktop/g4g-ipa/GamesforGroup.ipa \
--group 53b08d6b-3091-4939-af36-2fd3d2f0ce17 \
--test-notes "What changed" --locale en-US --wait
Success looks like: {"uploaded":true,"processingState":"VALID"}
PATCH comes back ATTRIBUTE.NOT_ALLOWED), and the only
alternative is deleting a group that has real testers in it. So every upload
used to sit on App Store Connect marked ready and reach nobody until someone
added the group by hand.This is now automatic, as the last step of
apps/app/scripts/build-local.sh. It waits out Apple's 5–15 minute
processing, then attaches the build to the group. To do it for a build that was
uploaded some other way:
cd apps/app
node scripts/attach-testflight-group.mjs 14 # the build number, always
It reads ASC_KEY_ID, ASC_ISSUER_ID, ASC_APP_ID
and ASC_BETA_GROUP_IDS from apps/app/.env.local-build
(gitignored), and the key from
~/.appstoreconnect/private_keys/. Neither eas submit nor
the asc CLI will do this: the first stops at "uploaded", and the
second refuses internal groups on the grounds that they receive everything
automatically — which is exactly the assumption that is false here.
Once attached, Karim installs from the TestFlight app.
Everything happens on Expo's servers: dependencies, the LAN guest export (automatic), signing (credentials already stored with EAS), and the upload.
First — sync the build number. EAS keeps its own build counter
(appVersionSource: remote in eas.json) and ignores app.json. After local
builds it will have fallen behind, and Apple rejects a number that didn't go up:
cd apps/app
eas build:version:get -p ios # what EAS thinks the number is
eas build:version:set -p ios # set it to (last TestFlight build + 1)
Then build and submit — two commands:
eas build --platform ios --profile production
eas submit --platform ios --latest
Notes:
autoIncrement bumps the EAS counter on every cloud build — the manual sync
is only needed when switching from local builds back to cloud.app.json → ios.buildNumber above the EAS counter. The two counters don't
talk to each other..env.production + the EXPO_PUBLIC_CONVEX_URL env var on EAS).export:lan-guest). It's the hook's first real run in the cloud.--group is required. (Older notes said don't pass it — the CLI changed.)
List groups: asc testflight beta-groups list --app 6790182940 --output tableerrSecInternalComponent, a stray
keychain is first in the search list. Fix:
security list-keychains -d user -s ~/Library/Keychains/login.keychain-db ~/3by4money-build.keychain-dbexpo prebuild --clean deletes the Podfile's :modular_headers lines. The
config plugin re-adds them — but never --clean casually.Same two paths as iOS: Path A (this Mac, free) is Steps 1–4 below; Path B (EAS cloud) follows them.
Bump it in both places, keeping them equal. Must be higher than any previous upload.
apps/app/app.json → expo.android.versionCode
apps/app/android/app/build.gradle → versionCode
Check the generated literal before building.apps/app/android/is gitignored and produced by prebuild — but Path A goes straight togradlewand never runs prebuild, so whatever number the directory was generated with LAST time still wins. Bumpingapp.jsonalone yields an AAB carrying the old number, and Play rejects it after a 6-minute build and a 144 MB upload.# after editing app.json, make the generated project agree: grep -n 'versionCode' apps/app/android/app/build.gradle # must match app.json # either edit that literal, or regenerate: cd apps/app && bunx expo prebuild -p androidSame shape as the iOS
CFBundleVersiontrap: a stale literal silently beating the value you just set.
cd apps/app
bun run export:lan-guest
cd android
export ANDROID_HOME="$HOME/Library/Android/sdk"
./gradlew bundleRelease
Output: app/build/outputs/bundle/release/app-release.aab (~132 MB)
Never pass -PreactNativeArchitectures=arm64-v8a for a Play build — that's
an emulator-testing shortcut that strips 3 of the 4 CPU types.
AAB=app/build/outputs/bundle/release/app-release.aab
keytool -printcert -jarfile $AAB | grep Owner # must NOT say "Android Debug"
unzip -l $AAB | grep -c "assets/guest-dist" # non-zero, matches the fresh export (grows with the app)
unzip -l $AAB | grep -oE "lib/[a-z0-9_-]+/" | sort -u # expect 4 ABIs
eas submit --platform android --profile production --path android/app/build/outputs/bundle/release/app-release.aab --non-interactive
This publishes straight to the internal testing track. It works because a
Play service account is wired in eas.json
(submit.production.android.serviceAccountKeyPath →
apps/app/play-service-account.json, gitignored; safe copy at
~/keystores/g4g-play-service-account.json). Google only forces the Console UI for
an app's first ever upload, which is long done.
Fallback (key lost or revoked): Play Console →
Test and release → Testing → Internal testing → Create new release
→ drop in the .aab → Save → Review release → Start rollout.
Testers install via the opt-in link (Testers tab → Copy link).
One-time first: give EAS the SAME upload keystore local builds use.
cd apps/app
eas credentials --platform android
# → production → Keystore → choose to UPLOAD an existing keystore:
# file ~/keystores/g4g-upload.jks
# alias + passwords: in ~/.gradle/gradle.properties (G4G_UPLOAD_*)
⚠️ Never let EAS "generate a new keystore" here. Play already trusts the current upload key — a build signed with a different one gets rejected until the key is reset through Play App Signing support.
Sync the version code (same two-counter rule as iOS — EAS ignores
app.json on cloud builds):
eas build:version:get -p android
eas build:version:set -p android # set it to (last Play upload + 1)
Build:
eas build --platform android --profile production
Then submit it without downloading:
eas submit --platform android --latest — the Play service account is already
wired (see Step 4).
~/keystores/g4g-upload.jks, password in ~/.gradle/gradle.properties.
Back both up. Wired in by plugins/withAndroidUploadSigning.js (a plugin,
because android/ is regenerated and a build.gradle edit would be wiped).
If those properties are missing the build silently falls back to debug
signing and Play will reject it — check Step 3.local.properties is deleted by every prebuild. If gradle says
"SDK location not found":
echo "sdk.dir=$HOME/Library/Android/sdk" > apps/app/android/local.properties./gradlew assembleRelease # → app/build/outputs/apk/release/app-release.apkThese docs are their own Cloudflare Worker (g4g-docs), completely separate
from the game. It serves the docs/ folder raw — no build, no Convex, nothing that can touch
the live app. A docs deploy is the safest deploy there is: it only ever moves HTML.
bun run deploy:docs
Config: docs/wrangler.jsonc (docs/.assetsignore keeps the config file
itself off the site), which attaches docs.gamesforgroup.com as its custom domain. It
also answers at
g4g-docs.games-for-group.workers.dev.
The app icon is the mascot's face on mint. Every picture of it, on every platform, is cut from three
drawings in apps/app/assets/icon-src/ by one script. Nothing is exported by hand.
| Drawing | What it is | What is cut from it |
|---|---|---|
face.svg | The icon's head: heavier line, bigger eyes, a hard shadow | The iPhone icon (mint) and its dark version (Midnight), the Android front layer, the web favicon,
touch icon, manifest icons and brand/logo.png |
face-mono.svg | The same head as a one-colour stencil | The iPhone tinted icon and the Android themed icon |
mascot.svg | The whole mascot standing still | The launch picture, light and dark |
cd apps/app
bun run icons
The script (apps/app/scripts/make-icons.mjs) photographs each drawing in the headless browser the
link-card stills already use, so it adds no dependency. app.json names the results: the iPhone icon
as three pictures (light, dark, tinted), the Android icon as a front layer and a one-colour layer over a mint
colour, and the launch screen as the still mascot, 180 points wide, on mint in the light look and Midnight in
the dark.
bunx expo prebuild and bunx expo run:ios (or the release build). The web pictures
go out with the next site deploy.src/ui/BootIntro.tsx, which
reads the size and the grounds from app.json), then lets the launch picture go. So
mascot.svg must stay equal to the mascot's resting drawing (src/ui/mascot/parts.tsx):
redraw one, redraw the other, and check a cold launch on the simulator for a jump.bunx expo run:ios --configuration Release on the
simulator, then reinstall the development build.Both live in apps/app/app.json — committed, and they survive prebuild:
"ios": { "buildNumber": "<N>" },
"android": { "versionCode": <N> }
Don't edit ios/…/Info.plist or android/app/build.gradle directly — those are
generated and regenerating resets them.
Two counters exist — one per build path. Local builds read app.json.
EAS cloud builds ignore it and use EAS's own remote counter (appVersionSource:
remote + autoIncrement in eas.json). Neither sees the other, so whenever
you switch paths, make the counter you're about to use beat the last uploaded
number: eas build:version:get / :set for cloud, app.json for local. The
store rejects any upload whose number didn't increase — that's the failure this
prevents.
| Store | Rule |
|---|---|
| Apple | buildNumber must increase every upload; version ("1.0.0") is the user-facing one |
| Play | versionCode must increase every upload; version is user-facing |
Local Network mode works with no internet: a friend's browser loads the game
page from the host's phone, so that page must ship inside the app binary.
bun run export:lan-guest builds it.
Two ways it used to break silently — both fixed, but know the shape:
postinstall hook.eas build --local) did not run that hook
either: the install happens at the monorepo root, and a workspace member's lifecycle script is
not the package manager's to run. Fixed with an eas-build-post-install hook, which
EAS itself invokes from this package._, and the export uses _expo/). Fixed in the module's build.gradle.Nothing is allowed to upload without it any more.
eas-build-pre-upload-artifacts runs
scripts/assert-guest-bundle.mjs, which fails the build if the export is empty or if
the platform copy was mirrored before the export ran. A raw xcodebuild archive runs
neither hook, so there you still export and re-pod by hand and check the archive yourself.
# the site — content pages + web play (build, check, upload to Cloudflare)
bun run deploy
# backend (only if convex/ or a game's rules changed) — BEFORE the site
bunx convex deploy -y
# live relay (only if live/ changed; secrets set once — see 2b)
# a live/src/ticket.ts change needs the backend deploy too
bun run deploy:live
# docs site
bun run deploy:docs
# ios — local, free: build, upload straight to Apple, attach the TestFlight group
cd apps/app && ./scripts/build-local.sh
# www redirect Worker (rarely changes)
bun run deploy:www
# ios — EAS cloud (sync counter first: eas build:version:get/set -p ios)
cd apps/app && eas build --platform ios --profile production && eas submit --platform ios --latest
# android — local (bump app.json android.versionCode first)
cd apps/app && bun run export:lan-guest && cd android && ./gradlew bundleRelease
cd .. && eas submit -p android --profile production --path android/app/build/outputs/bundle/release/app-release.aab --non-interactive
# android — EAS cloud (one-time: upload keystore via eas credentials; sync counter)
cd apps/app && eas build --platform android --profile production && eas submit -p android --latest
Key IDs: Apple team AL7Y7C2959 · ASC app 6790182940 · TestFlight group
53b08d6b-3091-4939-af36-2fd3d2f0ce17
· Convex prod clean-poodle-980 · package/bundle com.karimdev.gamesforgroup
· Cloudflare account f2338e0ebd1967dfaf52f7e8542e3541 (Workers games-for-group, g4g-docs, g4g-www-redirect, g4g-live; zone 70c9fb226aa1f221091541a2522ca50b)