Friends and social graph
1. Foundational mental model
Section titled “1. Foundational mental model”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:
| Table | Row means | Written by |
|---|---|---|
friendRequests | One directed request from fromUserId to toUserId with a status | sendFriendRequest, acceptFriendRequest, declineFriendRequest, cancelFriendRequest |
friendships | One undirected edge, stored once per pair | acceptFriendRequest (insert), removeFriend (delete) |
presence | One player’s current activity and lastSeen | updatePresence, presenceHeartbeat, clearPresence |
activityFeed | One 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.
2. Implementation status & known gaps
Section titled “2. Implementation status & known gaps”3. Concrete implementation
Section titled “3. Concrete implementation”Schema and indexes
Section titled “Schema and indexes”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"]),| Index | Used by |
|---|---|
friendships.by_pair | Already-friends check on send and accept, removeFriend |
friendships.by_user_a + by_user_b | listFriendUserIds, friend counts, mutual friends, account deletion |
friendRequests.by_pair_status | Duplicate and reverse pending checks on send |
friendRequests.by_to_user_status, by_from_user_status | Inbox and outbox, pending caps |
friendRequests.by_from_user, by_to_user | Account deletion |
presence.by_user_id | Presence upsert, friend list join |
activityFeed.by_user_and_time | getMyActivity, getFriendActivity |
friendRequests.by_pair has no reader in apps/web/src/convex/.
One row per pair
Section titled “One row per pair”The friendship edge is stored once. sortedUserPair compares the two Convex id strings and puts the smaller one in userIdA:
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:
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),];Request lifecycle
Section titled “Request lifecycle”sendFriendRequest runs its checks in this order and returns { success: false, reason } at the first failure:
- Authenticated, then one slot from the rolling-window throttle.
normalizeFriendCodeaccepts the code.- A
playersrow has that code (by_friend_code), it is not the sender, and it hasallowFriendRequests. - No
friendshipsrow for the sorted pair. - Both players under 50 friends, sender under 20 pending outgoing, recipient under 30 pending incoming.
- 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:
await context.db.patch(args.requestId, { status: "accepted" });
if (!existingFriendship) { await context.db.insert("friendships", { userIdA, userIdB, createdAt: Date.now() });}Friend code format
Section titled “Friend code format”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.
Presence
Section titled “Presence”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:
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”.
Activity feed
Section titled “Activity feed”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.
4. Internal mechanics & mathematics
Section titled “4. Internal mechanics & mathematics”Edge storage cost
Section titled “Edge storage cost”With one row per unordered pair, players with friends each store
rows. A two-row design would store 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.
Read fan-out
Section titled “Read fan-out”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):
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):
Mutual friends is a set intersection, , 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.
Friend code space
Section titled “Friend code space”The generator draws 6 characters from a 32-symbol alphabet (A–Z without I and O, plus 2–9):
With existing codes, a fresh draw collides with probability . At that is about , 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.
Rolling-window throttle
Section titled “Rolling-window throttle”nextRollingWindowTimestamps keeps a per-actor array of send times in rollingWindowThrottleState, keyed friend-request:<userId>. On each attempt at time it drops timestamps older than and allows the send when fewer than remain:
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.
Consistency
Section titled “Consistency”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 requiresrequest.fromUserId === userId.removeFriendlooks 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 codes makes random discovery impractical. Player search (
MAX_PLAYER_SEARCHES_PER_WINDOW = 40per 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.
declineFriendRequestdoes not checkstatus. Declining an already accepted request flips the row todeclinedand 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 oldacceptedrow stays. - Presence staleness. Without heartbeats,
lastSeenreflects the last activity change. Friends appear online for 2 minutes after each change and offline after that, even with the tab open.presence.tscomments describe a 30-second keep-alive that the client does not run. - Duplicate presence rows.
loadPresenceForUpdatereads up toINDEXED_DEDUPE_TAKE = 8rows 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.leaderboardOptOuthides the player from public leaderboards only. - Feed retention.
purgeOldActivityFeeddeletes non-daily rows older than 30 days at 03:10 UTC;daily_challengerows are kept. The friend feed applies its own 30-day window, so old daily rows never show there. - Account deletion.
account.tsdeletes the user’sfriendRequests(both directions),friendships(both sides),presence, andactivityFeedrows. Former friends lose the edge immediately.
6. Architectural tradeoffs & non-goals
Section titled “6. Architectural tradeoffs & non-goals”| Choice | Alternative | Why this one |
|---|---|---|
One friendships row per pair, sorted ids | Two directed rows | One write on accept and one delete on remove; no half-deleted friendships. Costs a second index read per list. |
Separate friendRequests table with status | Status column on friendships | Keeps the edge table to real friendships only, so list and count reads need no status filter. |
| Friend codes as the only way to send | Send by username | Stops drive-by requests from anyone who sees a name on a leaderboard. |
| Hard caps (50, 20, 30) | Paginated lists | Every read can .collect() and join in one query; the /social page renders the whole graph at once. |
| Presence in Convex with a client-side threshold | WebSocket presence on the Bun server | Friend 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 transaction | Feed derived from xpAwards at read time | xpAwards 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.