Skip to content

Four stores hold state, each with one owner:

StoreOwnerHoldsDurability
Convexapps/web/src/convexAccounts, XP, friends, daily completions, Learn, aggregated country stats, globe feed, replays, rewind rollupsManaged database, the system of record
stats.dbBun server (StatisticsManager)Per-country play volume and flag-difficulty counters, forward dedupe logFile on the VPS volume, default journal mode
diagnostics.dbBun server (DiagnosticsManager)Per-match event logs for incident debuggingFile on the VPS volume, WAL, 7-day retention
R2 bucket flagsBuild and upload scriptsFlag images, audio, daily OG images, SvelteKit client chunksObject storage behind cdn.flags.games and assets.flags.games

The browser keeps settings, pending XP tokens, seat tickets, and local progress in localStorage and sessionStorage. The service worker caches flag images, audio, and the app shell in Cache Storage. No code in apps/web/src or apps/web/static/sw.js opens IndexedDB.

schema.ts spreads authTables from @convex-dev/auth (authSessions, authAccounts, authRefreshTokens, authVerificationCodes, authVerifiers, authRateLimits) and defines 40 tables of its own, including an override of users.

GroupTables
Identityusers, players, usernameReservations
Socialfriendships, friendRequests, presence, activityFeed
XP and progressplayerStats, gameTokens, xpAwards, retentionSummaries
Daily challengedailyChallengeCompletions, dailyChallengeLeaderboardSnapshots, dailyChallengeDayArchives, dailyChallengeMetrics, processedDailyChallengeMetricEvents, dailyPlayVolumeSnapshots
LearnlearnCards, learnReviewEvents
Country statscountryStats, countryStatsStratum, countryPlayerSubmissions, pendingCountryStatEvents, countryStatPerformanceCredits, processedCountryStatEvents
TrusttrustProfiles, trustEvents, fingerprintCohortSessions
Globe and site activityglobePlayEvents, globeSnapshotCounters, siteActivityEvents, siteActivitySnapshotCounters, globalCounters
Replaysreplays
Rewind rollupsmonthlyXpRollups, monthlyCommunityRollups, userYearSnapshots, globalYearSnapshots
InfrastructurerollingWindowThrottleState, supporterWaitlist

apps/web/src/convex/http.ts exposes four trusted POST routes: /game-result (Bun), /replay-save (Bun), /daily-challenge-metrics (SvelteKit daily-session-validate.ts), and /challenge-completions (SvelteKit forward-challenge-completion.ts).

Both managers resolve apps/server/data/ and pick a file name by NODE_ENV:

apps/server/src/lib/diagnostics/diagnostics-manager.ts
const fileName =
env.NODE_ENV === "production" ? "diagnostics.production.db" : "diagnostics.db";
this.db = new Database(path.join(dataDir, fileName));
this.db.run("PRAGMA journal_mode = WAL");
apps/server/src/lib/managers/stats-management.ts
const fileName = env.NODE_ENV === "production" ? "stats.production.db" : "stats.db";
const dataDir = path.join(this.serverRootPath, "data");
mkdirSync(dataDir, { recursive: true });
const dbPath = path.join(dataDir, fileName);
this.db = new Database(dbPath);
stats.db tableKeyPurpose
country_statscountryCodePlayers and games per player country
country_stats_stratumcountryCode, difficulty, gameTypeAverage score, accuracy, response time per stratum
player_submissionsuserId, countryCodeFirst-seen check so totalPlayers counts each session once
convex_country_forward_logevent_keyReservation that blocks duplicate Convex forwards; rows older than 30 days are deleted, at most hourly
globe_actor_last_globe_emitactor_hashLast globe dot per anonymous actor (60 s cooldown)
globe_actor_country_last_globe_emitactor_hash, country_codeLast globe dot per actor and country (15 min cooldown)
flag_recognition_statscountryCodeTimes each flag was prompted and answered correctly
flag_confusion_statstargetCountryCode, guessedCountryCodeWrong-answer pairs
flag_prompt_stratumcountryCode, gameType, difficultyRecognition split by mode and difficulty
flag_option_exposuretargetCountryCode, optionCountryCode, gameType, difficultyHow often a distractor was offered and picked

diagnostics.db has diagnostic_matches, diagnostic_events, and diagnostic_event_overflow. A timer runs cleanup() every hour and deletes rows older than RETENTION_MS = 7 * 24 * 60 * 60 * 1000.

PrefixWritten byServed from
images/flags/… (readable and opaque o/ keys)bun run upload:flagshttps://cdn.flags.games/images/flags/…
audio/…bun run upload:audioCDN
assets/_app/immutable/…upload-client-build.ts during the web buildassets.flags.games Worker (WEB_ASSETS binding)
Daily OG images/api/revalidate cron via $lib/daily-oghttps://cdn.flags.games/static/og/daily/<utcDate>/…
KeyStorageContents
settingslocalStorageGameSettings, including includeExtendedTerritories
__convexAuthJWT_<deployment>, __convexAuthRefreshToken_<deployment>localStorage (legacy)Older @convex-dev/auth credentials. bootstrap-convex-auth-session.ts moves the refresh token into a cookie and removes both keys once the cookie write succeeds.
pendingXpTokens, pendingDailyXpTokenslocalStorage (legacy copy migrated out of sessionStorage)Signed XP tokens awaiting a signed-in claim
spaced-repetition-v2, flags-learn-review-pending-v1localStorageLearn card state and review events not yet synced
daily-session-v2localStorageIn-progress daily session
multiplayerSeatTicketsessionStorageReconnect credentials for a multiplayer seat, scoped to the tab
session_tokenhttpOnly cookieAnonymous session id (see Dual identity trust)

The country written to country_stats is the player’s country from edge geolocation (context.arcjet.countryCode), not a flag. If geolocation returns nothing or the player opted out of leaderboards, the response status is no_country or opted_out and no Convex forward starts. The flag tables are written regardless.

The forward key is built from game facts, so a retry of the same result produces the same key:

apps/server/src/lib/country-stats/convex-country-forward.ts
export function buildGlobeEventKey(params: {
gameKind: "solo" | "multiplayer";
roomId: string;
gameStartTime: number;
userId: string;
totalQuestions: number;
correctAnswers: number;
score: number;
}): string {
if (params.gameKind === "solo") {
return `globe:solo:${params.userId}:${params.gameStartTime}:${params.totalQuestions}:${params.correctAnswers}:${params.score}`;
}
return `globe:multi:${params.roomId}:${params.gameStartTime}:${params.userId}`;
}

The local reservation is one statement:

apps/server/src/lib/country-stats/convex-country-forward-reservation.ts
const INSERT_RESERVED =
"INSERT OR IGNORE INTO convex_country_forward_log (event_key, reserved_at) VALUES (?, ?)";
export function reserveConvexCountryForwardSlot(
database: SqliteRunTarget,
eventKey: string,
reservedAtMs: number
): boolean {
const outcome = database.run(INSERT_RESERVED, [eventKey, reservedAtMs]);
return outcome.changes > 0;
}

changes > 0 means this process won the key. On a non-OK response or network error, onComplete({ ok: false }) deletes the row.

With rr forwards per day and the 30-day prune, the log holds at most about 30r30r rows between hourly cleanups:

rows≤r⋅30+r⋅124\text{rows} \le r \cdot 30 + r \cdot \tfrac{1}{24}

The second term is the worst case of one missed hourly prune window.

For an actor hash aa and player country cc, the server sets skipGlobeFeed when

t−last(a)<60 s∨t−last(a,c)<15 mint - \text{last}(a) < 60\,\text{s} \quad\lor\quad t - \text{last}(a, c) < 15\,\text{min}

The stats row is still recorded in Convex; only the anonymous globe dot is skipped. globeActorHash is the first 16 hex characters of HMAC-SHA256(secret,sessionUserId)\text{HMAC-SHA256}(\text{secret}, \text{sessionUserId}), so the session id never reaches Convex’s globe tables.

5. Threat model, failure modes & edge cases

Section titled “5. Threat model, failure modes & edge cases”
  • Lost forwards. A Convex outage during game end drops the forward for that result. SQLite still has the local row, so /api/leaderboard/countries on the Bun server and Convex countryStats can disagree until more games arrive. No reconciliation job exists.
  • Single-writer SQLite. bun:sqlite calls are synchronous on the event loop. stats.db in rollback-journal mode blocks readers during a write; WAL on diagnostics.db lets reads continue while events append. Flag observations are batched in one db.transaction per result.
  • Server restart. Room state, replay buffers, and the in-memory trust map are lost. SQLite files survive because they are on the Coolify volume. A match in progress at restart never forwards.
  • Idle games. processGameResults returns early when no answers were recorded, and Convex /game-result also answers recorded: false when answersRecorded === 0.
  • Retention mismatch. Diagnostics keep 7 days, the forward log 30 days, and Convex purges processedCountryStatEvents on a cron. A forward with a previously seen eventKey that arrives after both windows would be counted twice. Keys include gameStartTime, so this needs a resubmission of the same result weeks later.
  • Browser tokens. Pending XP tokens sit in localStorage in plain text. They are signed and bound to claimUserId, so a copied token only works for the account it names, and Convex rejects it after expiresAt (30 minutes).
ChoiceAlternativeWhy this one
SQLite on the game server for hot countersWrite every game straight to ConvexThe server can answer /api/leaderboard/countries and flag-difficulty queries without a network hop, and a Convex outage does not block gameplay.
Fire-and-forget Convex forwardDurable outbox table with retriesSimpler, and a missed stats row is low cost. The reservation release leaves room for a retry path later.
WAL only on diagnostics.dbWAL on bothDiagnostics append many small rows and are read by the MCP tools during incidents. stats.db has not needed it.
Aggregation through pendingCountryStatEvents and a 5-minute cronUpdate countryStats inside /game-resultKeeps the HTTP action short and avoids write contention on hot country rows.
One shared secret for all trusted writesPer-route credentialsOne value to rotate, with _PREV for overlap. The cost is a large blast radius if it leaks.

Non-goals: exactly-once delivery from Bun to Convex, cross-region SQLite replication, and storing raw IPs or session ids in Convex globe tables.