Monorepo topology
1. Foundational mental model
Section titled “1. Foundational mental model”flags.games is one Bun workspace with five apps and four internal packages. Two Cloudflare Workers live in the same repository but sit outside the workspace list, so Bun and Turbo never see them.
The root package.json declares the boundary:
"workspaces": [ "apps/*", "packages/*"],Everything that ships to players flows from @flags/shared. The web app pulls in all four packages; the game server pulls in only @flags/shared; the docs site pulls in @flags/shared and @flags/flavitars so its simulators run production code.
flags.games_email (React Email templates) and flags.games_mobile (Capacitor shell) are workspaces with no internal dependencies. @flags/fingerprint and @flags/flavitars have no internal dependencies either.
2. Implementation status & known gaps
Section titled “2. Implementation status & known gaps”3. Concrete implementation
Section titled “3. Concrete implementation”Workspaces and packages
Section titled “Workspaces and packages”| Folder | Package name | Role | Build script |
|---|---|---|---|
apps/web | flags.games_web | SvelteKit app, API routes, Convex backend (src/convex) | bun scripts/build.ts |
apps/server | flags.games_server | Bun HTTP + WebSocket game server, SQLite | tsc --noEmit |
apps/docs | flags.games_docs | This site (Astro + Starlight) | astro check && astro build |
apps/email | flags.games_email | React Email templates | email export --outDir dist --dir emails |
apps/mobile | flags.games_mobile | Capacitor remote shell for https://flags.games | none |
packages/shared | @flags/shared | Country data, game logic, schemas, constants | tsc |
packages/audio | @flags/audio (exports . and ./lite) | Tone.js rich audio and a raw Web Audio lite entry | tsup |
packages/fingerprint | @flags/fingerprint | Browser signal collection and scoring | tsc |
packages/flavitars-sdk | @flags/flavitars | Deterministic avatar SVG renderer | tsc |
workers/static | static-worker (not a workspace) | Analytics proxy on static.flags.games | no-op |
workers/assets | assets-worker (not a workspace) | SvelteKit immutable chunks from R2 on assets.flags.games | no-op |
workers/lib holds plain JS helpers shared by both Workers (mime-by-extension.js, r2-client-build-keys.js, read-capped-body.js).
Where each piece runs
Section titled “Where each piece runs”| Target | Hosted on | How it deploys |
|---|---|---|
apps/web | Vercel | apps/web/vercel.json: installCommand runs bun install --frozen-lockfile at the repo root, buildCommand runs bun run build:web. One cron: /api/revalidate at 0 0 * * *. |
apps/web/src/convex | Convex | Manual bun run deploy:convex (cd apps/web && bunx convex deploy). The schedule generator reads prod archives from https://convex.flags.games. |
apps/server | Hostinger VPS, Coolify app for api.flags.games | Coolify builds apps/server/Dockerfile (oven/bun:1.2.19, PORT=8080, CMD ["bun", "run", "src/app.ts"]). |
workers/static, workers/assets | Cloudflare Workers | .github/workflows/deploy-workers.yml on pushes to master that touch workers/**, or bun run deploy:worker locally. |
apps/docs | Vercel project flags-games-docs | bunx turbo run build --filter=flags.games_docs. |
The web build uploads hashed client chunks to R2 before Vercel finishes, so the assets Worker can serve them:
run("bunx", ["svelte-kit", "sync"]);run("bun", ["scripts/copy-sentry-replay-worker.ts"]);
process.env = withNodeHeapLimit();run("bunx", ["vite", "build"]);if (shouldUploadClientBuild()) { run("bun", ["scripts/r2/upload-client-build.ts"]);} else { console.log( "Skipping R2 client build upload (preview/local or PUBLIC_SVELTEKIT_ASSETS_ORIGIN unset)." );}run("bun", ["scripts/patch-vercel-headers.ts"]);The assets Worker binds the same bucket and serves only the immutable prefix:
# SvelteKit client build: R2 keys `assets/_app/immutable/*` in bucket `flags` (default).[[r2_buckets]]binding = "WEB_ASSETS"bucket_name = "flags"The static Worker has no R2 binding. It routes /phi to PostHog, /vemetric to Vemetric, and POST /sentry to Sentry, and returns 404 for everything else (workers/static/src/index.js).
Dev commands
Section titled “Dev commands”| Command | What runs | Ports |
|---|---|---|
bun dev | turbo dev: every workspace with a dev script (web, server, docs, email, flavitars watch) | web 3000, server 3001, docs 4321, email 3030 |
bun run dev:web | turbo dev --filter=flags.games_web --filter=flags.games_server --filter=@flags/flavitars | 3000, 3001 |
bun run dev:email | React Email preview only | 3030 |
bun run dev:worker / dev:worker:assets | wrangler dev in workers/static / workers/assets | 8787 / 8788 |
bun run dev:convex | bunx convex dev from apps/web | Convex cloud dev deployment |
dev:web includes the game server despite its name. The server’s dev script runs from the repo root with bun --env-file=apps/server/.env --watch apps/server/src/app.ts.
4. Internal mechanics & mathematics
Section titled “4. Internal mechanics & mathematics”Turbo task graph
Section titled “Turbo task graph”"build": { "inputs": ["$TURBO_DEFAULT$", ".env*"], "dependsOn": ["^build"], "outputs": ["dist/**", ".svelte-kit/**"]},"flags.games_web#typecheck": { "dependsOn": ["^build", "^typecheck"], "outputs": []},"@flags/flavitars#dev": { "dependsOn": ["build"], "cache": false, "persistent": true}^build means “build my dependencies first”. For bun run build:web (turbo run build --filter=flags.games_web), Turbo walks the graph above and produces this order:
Only @flags/audio waits on @flags/shared. fingerprint and flavitars could start in wave 1; Turbo schedules them as soon as a worker is free. The critical path is , and the web step (Vite build plus R2 upload) dominates it.
The per-task env lists on flags.games_web#build and flags.games_server#build feed Turbo’s cache hash. envMode: "loose" still passes the full process environment to tasks; the root AGENTS.md records that strict mode caused spurious EADDRINUSE on Bun.serve under turbo dev on Windows.
Package entry points
Section titled “Package entry points”Built packages set "main": "./dist/index.js", but most runtime code imports source paths directly, for example @flags/shared/src/data/countries. The Bun server runs TypeScript source without a build, and the Docker image copies packages/shared as source. @flags/shared has no exports map, so any file under src/ is importable.
5. Threat model, failure modes & edge cases
Section titled “5. Threat model, failure modes & edge cases”- Shared-package blast radius. A change to
packages/sharedreaches the web app, the server, and the docs site in one commit, but they deploy on three different schedules (Vercel on push, Coolify on push or redeploy, Convex by hand). A WebSocket schema change can be live on the server before the web client that speaks it. Keep schema changes additive across one deploy cycle. - Server image skips tests. The
Dockerfilecomment says tests run inserver-ci.yml, not during the image build. A Coolify redeploy from a branch that failed CI still builds. - Chunk upload before deploy. With
R2_UPLOAD_CLIENT_BUILD_REQUIRED=1, missing R2 credentials makeupload-client-build.tsexit 1 and the Vercel build fails. Without the flag, the script logs a warning and returns, while HTML built withPUBLIC_SVELTEKIT_ASSETS_ORIGINset still points chunk URLs atassets.flags.games. That combination ships pages whose chunks 404. - Workers drift silently. The Workers are not in Turbo’s graph, so
bun run buildandtypecheck:webdo not cover them. Their only automated gate is the deploy workflow itself. - Local ports.
resolveFlagsApiBaseUrl()inapps/web/src/lib/env/game-server-urls.tsfalls back tohttp://<page hostname>:3001/apiin the browser. A LAN phone hittinghttp://192.168.x.x:3000reaches the server on the same host, which is what makes thesession_tokencookie flow work in dev.
6. Architectural tradeoffs & non-goals
Section titled “6. Architectural tradeoffs & non-goals”| Choice | Alternative | Why this one |
|---|---|---|
Convex source inside apps/web/src/convex | A separate apps/convex workspace | The web app imports generated api types directly through $lib/convex-browser; one tree avoids a second copy of _generated. |
| Bun server on a VPS | Serverless functions | Rooms, timers, heartbeats, and SQLite need a long-lived process. |
Workers outside workspaces | Add workers/* to the root list | Wrangler and its dependency tree stay out of the root lockfile and the server Docker install. |
Source imports from @flags/shared/src/... | Consume only dist/ | The server and docs run TypeScript without a prior package build. The cost is that tsc output and source can diverge if someone imports dist directly. |
One R2 bucket (flags) for flags, audio, OG images, and client chunks | A bucket per asset class | One credential set and one upload helper. Key prefixes (images/flags/, audio/, assets/_app/immutable/) keep the classes apart. |
Non-goals: running Workers under turbo dev, publishing any @flags/* package to npm, or deploying the game server from Turbo.