Skip to content

The social graph is small and bounded. Each signed-in player has a friend code like FLAGS-7KQ2XM, up to 50 friends, and a capped inbox of pending requests. Friends see each other’s presence (browsing, playing, in lobby, idle, or last seen), level, streak, and the last 30 days of activity events.

Four Convex tables carry it:

TableRow meansWritten by
friendRequestsOne directed request from fromUserId to toUserId with a statussendFriendRequest, acceptFriendRequest, declineFriendRequest, cancelFriendRequest
friendshipsOne undirected edge, stored once per pairacceptFriendRequest (insert), removeFriend (delete)
presenceOne player’s current activity and lastSeenupdatePresence, presenceHeartbeat, clearPresence
activityFeedOne event for one player (game_completed, daily_challenge, streak_milestone, level_up, weekly_bonus)XP and completion mutations via emitActivityEvent

A request is directed. A friendship is not. The request table keeps the history of who asked whom; the friendship table answers “are these two friends” with one indexed lookup.

The UI lives at /social. The route file renders DeferredRouteContent for "/social", which loads SocialPageClient.svelte with five tabs selected by ?tab=: me, friends (default, no query param), requests, leaderboard, activity. Guests see SocialGuestUpsell instead.

apps/web/src/convex/schema.ts
friendships: defineTable({
userIdA: v.id("users"),
userIdB: v.id("users"),
createdAt: v.number(),
})
.index("by_user_a", ["userIdA"])
.index("by_user_b", ["userIdB"])
.index("by_pair", ["userIdA", "userIdB"]),
friendRequests: defineTable({
fromUserId: v.id("users"),
toUserId: v.id("users"),
status: v.union(v.literal("pending"), v.literal("accepted"), v.literal("declined")),
createdAt: v.number(),
})
.index("by_from_user", ["fromUserId"])
.index("by_to_user", ["toUserId"])
.index("by_pair", ["fromUserId", "toUserId"])
.index("by_to_user_status", ["toUserId", "status"])
.index("by_from_user_status", ["fromUserId", "status"])
.index("by_pair_status", ["fromUserId", "toUserId", "status"]),
IndexUsed by
friendships.by_pairAlready-friends check on send and accept, removeFriend
friendships.by_user_a + by_user_blistFriendUserIds, friend counts, mutual friends, account deletion
friendRequests.by_pair_statusDuplicate and reverse pending checks on send
friendRequests.by_to_user_status, by_from_user_statusInbox and outbox, pending caps
friendRequests.by_from_user, by_to_userAccount deletion
presence.by_user_idPresence upsert, friend list join
activityFeed.by_user_and_timegetMyActivity, getFriendActivity

friendRequests.by_pair has no reader in apps/web/src/convex/.

The friendship edge is stored once. sortedUserPair compares the two Convex id strings and puts the smaller one in userIdA:

apps/web/src/convex/lib/friendsPair.ts
export function sortedUserPair<T extends string>(userIdA: T, userIdB: T): [T, T] {
return userIdA < userIdB ? [userIdA, userIdB] : [userIdB, userIdA];
}

Reading a friend list therefore needs both sides of the edge:

apps/web/src/convex/lib/friendships.ts
const [friendshipsUserA, friendshipsUserB] = await Promise.all([
context.db
.query("friendships")
.withIndex("by_user_a", (query) => query.eq("userIdA", userId))
.collect(),
context.db
.query("friendships")
.withIndex("by_user_b", (query) => query.eq("userIdB", userId))
.collect(),
]);
return [
...friendshipsUserA.map((friendship) => friendship.userIdB),
...friendshipsUserB.map((friendship) => friendship.userIdA),
];

sendFriendRequest runs its checks in this order and returns { success: false, reason } at the first failure:

  1. Authenticated, then one slot from the rolling-window throttle.
  2. normalizeFriendCode accepts the code.
  3. A players row has that code (by_friend_code), it is not the sender, and it has allowFriendRequests.
  4. No friendships row for the sorted pair.
  5. Both players under 50 friends, sender under 20 pending outgoing, recipient under 30 pending incoming.
  6. No pending request in the same direction, and none in the reverse direction.

Then it inserts a pending row. Accept re-checks both friend counts only when no edge exists yet, patches the request to accepted, and inserts the edge:

apps/web/src/convex/friends.ts
await context.db.patch(args.requestId, { status: "accepted" });
if (!existingFriendship) {
await context.db.insert("friendships", { userIdA, userIdB, createdAt: Date.now() });
}
packages/shared/src/social/friend-code.ts
const FRIEND_CODE_CHARS = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
const FRIEND_CODE_LENGTH = 6;
export const FRIEND_CODE_REGEX = /^FLAGS-[A-Z2-9]{4,6}$/;

generateUniqueFriendCode in players.ts draws a candidate, checks by_friend_code, and retries up to 10 times before throwing.

updatePresence(activity) upserts the caller’s row with a new activity and lastSeen = Date.now(). presenceHeartbeat bumps only lastSeen. clearPresence deletes the row and runs from account.signOut() before the auth sign-out. On the client, isFriendOnline decides the dot:

apps/web/src/lib/social/friend-presence.ts
export const FRIEND_ONLINE_THRESHOLD_MS = 2 * 60 * 1000;
export function isFriendOnline(lastSeen: number, now: number): boolean {
return lastSeen > 0 && now - lastSeen < FRIEND_ONLINE_THRESHOLD_MS;
}

getFriends returns activity: presence?.activity ?? "Offline" and lastSeen: 0 for friends with no presence row, which the client renders as “Offline”.

emitActivityEvent inserts the event in the same transaction as the XP award, and adds a level_up row at occurredAt + 1 when the level changes. recordActivity is an internalMutation, so clients cannot write feed events. getFriendActivity takes now as an argument (Convex queries should not read the clock), caps friends at FRIEND_LIMIT, reads EVENTS_PER_FRIEND_FOR_ACTIVITY = 25 recent events per friend inside a 30-day window, merges, sorts newest first, and slices to limit (default 50, clamped to 1–200).

The /social client caches the friend list and requests in localStorage under social-snapshot-v1 so the page paints before the Convex subscription resolves. signOut clears it.

With one row per unordered pair, nn players with FF friends each store

∣E∣=nF2|E| = \frac{nF}{2}

rows. A two-row design would store nFnF and need both rows written and deleted together. The cost of the single row is on the read side: a friend list is two indexed range reads instead of one.

For a player at the cap, getFriends issues one friendship read per side plus four lookups per friend (players, presence, playerStats, and the retention summary helper):

2+4⋅50=202 index reads (at least)2 + 4 \cdot 50 = 202 \text{ index reads (at least)}

getFriendRequests reads the inbox and outbox, the viewer’s friend list, one profile per sender and recipient, and each sender’s friend list for the mutual count. With full queues (30 incoming, 20 outgoing):

2⏟queues+2⏟viewer list+30+20⏟profiles+2⋅30⏟sender lists=114 index reads\underbrace{2}_{\text{queues}} + \underbrace{2}_{\text{viewer list}} + \underbrace{30 + 20}_{\text{profiles}} + \underbrace{2 \cdot 30}_{\text{sender lists}} = 114 \text{ index reads}

Mutual friends is a set intersection, ∣Fviewer∩Fsender∣|F_\text{viewer} \cap F_\text{sender}|, done in memory by countMutualFriendsFromSets. The .collect() calls are safe only because the caps bound every list; they are enforced on write, so a data import that bypassed them would make these queries grow.

The generator draws 6 characters from a 32-symbol alphabet (A–Z without I and O, plus 2–9):

326=230=1,073,741,82432^6 = 2^{30} = 1{,}073{,}741{,}824

With kk existing codes, a fresh draw collides with probability k/230k / 2^{30}. At k=106k = 10^6 that is about 9.3×10−49.3 \times 10^{-4}, so 10 retries is ample. The validation regex is looser than the generator: it allows 4 to 6 characters and the letters I and O, so shorter or older codes still normalize.

nextRollingWindowTimestamps keeps a per-actor array of send times in rollingWindowThrottleState, keyed friend-request:<userId>. On each attempt at time tt it drops timestamps older than t−Wt - W and allows the send when fewer than LL remain:

allowed(t)  ⟺  ∣{ s∈S:s≥t−W }∣<L,W=300 000 ms, L=20\text{allowed}(t) \iff \big|\{\, s \in S : s \ge t - W \,\}\big| < L, \qquad W = 300\,000 \text{ ms},\ L = 20

The stored array is capped at ROLLING_WINDOW_THROTTLE_ARRAY_CAP = 64. A slot is consumed before code validation, so mistyped codes count toward the limit.

Every mutation above is a Convex transaction with serializable isolation. Two players who send each other a request at the same moment both read by_pair_status for the reverse direction. One commit invalidates the other’s read set, the loser retries, sees the reverse pending request, and gets “This player already sent you a request”. The same retry protects the friend-count checks on accept.

5. Threat model, failure modes & edge cases

Section titled “5. Threat model, failure modes & edge cases”
  • Authorization. Accept and decline require request.toUserId === userId. Cancel requires request.fromUserId === userId. removeFriend looks up the sorted pair containing the caller, so a caller can only delete an edge they are part of. Failures return { success: false } without saying why.
  • Code enumeration. A 20-per-5-minute budget against 2302^{30} codes makes random discovery impractical. Player search (MAX_PLAYER_SEARCHES_PER_WINDOW = 40 per 5 minutes) is a separate throttle.
  • Inbox flooding. Many accounts can fill one player’s 30 incoming slots. Once full, legitimate senders get “This player’s request inbox is full” until the player declines. There is no report or block action.
  • Decline after accept. declineFriendRequest does not check status. Declining an already accepted request flips the row to declined and leaves the friendship edge in place. The UI only offers decline on pending rows, so this needs a direct API call and has no effect on the graph.
  • Re-requests. Only pending rows block a new send. A declined sender can send again immediately, limited only by the throttle. After removeFriend, either player can send a fresh request; the old accepted row stays.
  • Presence staleness. Without heartbeats, lastSeen reflects the last activity change. Friends appear online for 2 minutes after each change and offline after that, even with the tab open. presence.ts comments describe a 30-second keep-alive that the client does not run.
  • Duplicate presence rows. loadPresenceForUpdate reads up to INDEXED_DEDUPE_TAKE = 8 rows for the user and deletes all but the first, so a race that inserts two rows heals on the next update.
  • What friends see. Username, avatar, XP and level, streak, friend code, gamesPlayed, presence, and feed events including scores, XP breakdowns, daily win and guess count, and weekly spin tier. leaderboardOptOut hides the player from public leaderboards only.
  • Feed retention. purgeOldActivityFeed deletes non-daily rows older than 30 days at 03:10 UTC; daily_challenge rows are kept. The friend feed applies its own 30-day window, so old daily rows never show there.
  • Account deletion. account.ts deletes the user’s friendRequests (both directions), friendships (both sides), presence, and activityFeed rows. Former friends lose the edge immediately.
ChoiceAlternativeWhy this one
One friendships row per pair, sorted idsTwo directed rowsOne write on accept and one delete on remove; no half-deleted friendships. Costs a second index read per list.
Separate friendRequests table with statusStatus column on friendshipsKeeps the edge table to real friendships only, so list and count reads need no status filter.
Friend codes as the only way to sendSend by usernameStops drive-by requests from anyone who sees a name on a leaderboard.
Hard caps (50, 20, 30)Paginated listsEvery read can .collect() and join in one query; the /social page renders the whole graph at once.
Presence in Convex with a client-side thresholdWebSocket presence on the Bun serverFriend list and presence come back from one reactive Convex query (getFriends), next to the rest of the account data.
Feed rows written in the award transactionFeed derived from xpAwards at read timexpAwards is purged at 90 days and has no scores; inline writes cannot drift from the award.

Non-goals: chat or messaging, following without consent, blocking and reporting, friend suggestions, and cross-device live presence beyond the 2-minute threshold.