← All docs Games for Group · Releasing

Releasing — every flow, start to finish

The tracks ship independently. Know which one you're touching.

WhatWhereHow
The site (content pages + web play)Cloudflare Worker games-for-group at gamesforgroup.com (www redirects to it)Manual — bun run deploy
BackendConvex clean-poodle-980Manual — only if convex/, a bundled game.ts or a game's content/online.server.ts changed, and before the site
iOS appTestFlight → App StoreLocal build ~15 min (free), or EAS cloud
Android appPlay internal testingLocal build ~10 min (free), or EAS cloud
Docs siteCloudflare Worker g4g-docsManual — 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 (an eas-build-post-install hook) and refuses to upload an artifact that does not contain it (eas-build-pre-upload-artifacts → scripts/assert-guest-bundle.mjs). A raw xcodebuild archive runs neither, so if you go that route, run bun run export:lan-guest and then pod install by hand — iOS mirrors the export at pod install time.

0. Before the first PUBLIC release — strip the design-system links

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.mjs deletes catalog.html and fx.html from 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.

1. The site (gamesforgroup.com) — one command

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.

StepWhat happens
BuildThe 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 rulesbuild-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.
GuardsThe 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.
Uploadbunx 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.

Domain and DNS

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.

2. Backend (Convex) — only if 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 BEFORE bun 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. Every game.ts is bundled into the backend too, so a rules change needs this even when convex/ itself is untouched.

Answer lists: which deploy carries which list

A game whose content is a secret has two lists (adding a game, "Content that is a secret"). They ship by different roads:

What changedWhat 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.


2a. The media host (how-to-play videos) — only when a video is new or re-made

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

2b. The live relay (g4g-live) — only if live/ changed

The 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.ts ships twice. Convex bundles it to sign passes and the relay bundles it to check them. A change to it needs BOTH bunx convex deploy -y and bun run deploy:live, or passes stop matching and the live line goes quiet everywhere, with no error.
Without the secret on both sides, or without EXPO_PUBLIC_LIVE_URL in the build, there is simply no live line: drawings appear a stroke at a time and nothing errors. Without LIVE_HTTP_URL on the backend, a removed player is not kicked off the relay at once, only refused at their next connect. Locally: bun run live:dev with LIVE_SECRET in live/.dev.vars and the same value in the dev backend (bunx convex env set LIVE_SECRET …). The dev backend has no LIVE_HTTP_URL: it runs in the cloud and cannot reach a relay on localhost.

2c. Limits the owner sets — dashboards, before the app is promoted

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.

SettingWhereValueWhy
Convex planConvex dashboard → team settings → BillingProfessional, before any promotionThe 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 limitsThe 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 callsConvex dashboard → production deployment (clean-poodle-980) → Settings → Usage limitsDaily: warning 1,000,000, disable 15,000,000The 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 egressThe same Usage limits pageDaily: warning 5 GB, disable 50 GBEvery 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 budgetThe Gemini and DeepSeek consoles, on each API keyA 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 planCloudflare dashboard → Workers & Pages → PlansWorkers Paid ($5 a month), at launchThe 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 ruleCloudflare 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 secondsCovers 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.

Check once on production

# 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

The two switches, for a bad day

ToDo
Stop every question set being written, at oncebunx convex env set AI_PACKS_ENABLED 0 --prod. Back on: set it to 1. No deploy, no app release.
Stop one account hostingConvex 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.

3. iOS → TestFlight

Two ways to build — both land in the same TestFlight group:

Path A — this MacPath B — EAS cloud
CostFreeOne EAS build credit
Your MacBusy ~20 minFree — build runs on Expo's servers
LAN exportAutomatic (build hook)Automatic (build hook)
Build numberapp.json — one counter, in git, for both

Use Path A while credits are scarce; Path B when you have credits or a pro plan.

Path A — build on this Mac (free)

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):

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.)

Check it before you upload

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.

If you archive with raw xcodebuild instead

None 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.

Exporting the .ipa by hand

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

Publishing it by hand

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"}

⚠️ The Internal group does NOT auto-receive builds It looks like it should, and App Store Connect gives no hint otherwise — but a beta group only receives every build automatically when it was created with that flag set, and ours was not. Apple refuses to change it afterwards (a 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
⚠️ Never attach "the newest build" It defaulted to that once and got it wrong on the very first automated run: Apple takes minutes to ingest, so seconds after an upload the newest build is still the PREVIOUS one. It attached build 13, printed a green tick, and left 14 stranded — a success message over the wrong build, which is worse than a failure. The build number is now required, and the script waits for that exact build rather than settling for whatever is there.

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.

Path B — EAS cloud build (uses build credits)

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:

iOS gotchas


4. Android → Play

Same two paths as iOS: Path A (this Mac, free) is Steps 1–4 below; Path B (EAS cloud) follows them.

Path A — build on this Mac (free) — Steps 1–4

Step 1 — bump the version code

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 to gradlew and never runs prebuild, so whatever number the directory was generated with LAST time still wins. Bumping app.json alone 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 android

Same shape as the iOS CFBundleVersion trap: a stale literal silently beating the value you just set.

Step 2 — build the bundle

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.

Step 3 — check it before uploading

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

Step 4 — upload (one command)

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).

Path B — EAS cloud build (uses build credits)

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).

Android gotchas


5. Docs site (docs.gamesforgroup.com) — manual, static

These 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 icon and the launch screen

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.

DrawingWhat it isWhat is cut from it
face.svgThe 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.svgThe same head as a one-colour stencil The iPhone tinted icon and the Android themed icon
mascot.svgThe 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.


Version numbers

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.

StoreRule
ApplebuildNumber must increase every upload; version ("1.0.0") is the user-facing one
PlayversionCode must increase every upload; version is user-facing

Why the LAN export matters

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:

  1. Cloud builds never ran the export → shipped without it. Fixed with a postinstall hook.
  2. Local builds (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.
  3. Android stripped the page's JavaScript (aapt ignores folders starting with _, 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.


Quick reference

# 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)

Games for Group · Releasing — every ship flow, start to finish.