0) Problem Restatement
Roblox asked: on each game's page (and in game lists), show a social proof line like "Played by Alex, Sam and 4 other friends" for the viewer. It must be personalized (it's your friends), reasonably fresh (recent plays, e.g., the last 30 days), fast (game lists show dozens of games at once) and respect privacy settings. The interviewer wanted it decomposed into separate distributed-systems problems.
1) Sub-Problems
- Friend graph: who are my friends (up to a few hundred, sometimes thousands)?
- Play events: who played which game and when (billions of events)?
- Summary serving: for (viewer, game), count and pick names of friends who played recently, for many games per page.
2) Two Approaches
A) Query time (pull): when the viewer loads the page, take their friend list, and for each game check which friends are in that game's recent players.- Store
recent_players[game]as a set with TTL entries, and intersect it with the friends set. - Problem: popular games have millions of recent players. Checking each friend's membership instead (friends × games lookups, e.g., 200 × 30 = 6,000) is okay with a fast store, but heavy.
played_by[friend][game] += {player, time}.
- Reading becomes one lookup per viewer:
played_by[viewer]→ a map of game → friends who played. Great for pages with many games. - Cost: each play event writes to N friends' entries (N ≈ average friend count ~50–200). Many plays per day × friends = lots of writes, but they're cheap, asynchronous and batchable.
- Hybrid: users with huge friend counts (thousands) don't fan out, and their contribution is computed at read time.
3) Architecture
Architecture Diagram
flowchart LR
GS["Game servers - session start"] --> K[("Play events")]
K --> DED["Dedup - once per user per game per day"]
DED --> FO["Fan-out workers"]
FG[("Friend graph service")] --> FO
FO --> PB[("played_by store - viewer to game to friends")]
PAGE["Game page / list API"] --> PB
PAGE --> PRIV["Privacy filter"]- Dedup first: a user playing a game 20 times a day should fan out once per day.
- played_by store: a key-value store keyed by viewer, value = map of game → a small list of (friend, last_played), trimmed to recent entries (a TTL of 30 days) and capped per game (e.g., keep 10 names plus a count).
- Read: one KV read per viewer per page, then pick the entries for the games on the page.
4) Privacy and Freshness
- Respect settings like "don't show my activity". Filter at fan-out time and again at read time (settings can change).
- Unfriending: remove entries lazily at read (check that the friend is still a friend) and in periodic cleanup.
- Freshness: fan-out is async (seconds to minutes), which is fine for social proof.
5) Wrap-Up
Split the feature into a friend graph, a deduplicated play-event stream, and a per-viewer "played_by" store. Fan out each (deduplicated) play to the player's friends' entries, so a game page or list needs a single read per viewer. Use a hybrid read-time path for users with huge friend lists, keep entries trimmed with TTLs and caps, and apply privacy filters on both write and read.