Problem RestatementProblem
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.
Sub-ProblemsProblem
- 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.
Deep Dive — "Played by Alex, Sam and 4 other friends"Deep dive
The line is personalised to the viewer and appears on every game card in a list. Whatever computes it runs dozens of times per page view.
Intersect the game's players with the viewer's friends
Load the game's recent players and intersect with the viewer's friend list.
%%{init: {"look":"handDrawn","handDrawnSeed":7,"theme":"base","fontFamily":"Virgil, \"Segoe Print\", \"Comic Sans MS\", cursive","themeVariables":{"fontFamily":"Virgil, \"Segoe Print\", \"Comic Sans MS\", cursive","fontSize":"16px","primaryColor":"#fff4e6","primaryBorderColor":"#1e1e1e","primaryTextColor":"#1e1e1e","secondaryColor":"#e7f5ff","tertiaryColor":"#ebfbee","lineColor":"#1e1e1e","textColor":"#1e1e1e","mainBkg":"#fff4e6","nodeBorder":"#1e1e1e","clusterBkg":"#f8f9fa","edgeLabelBackground":"#ffffff","classText":"#1e1e1e"}}}%%
flowchart LR
G["Popular game - 4,000,000 recent players"] --> LOAD["Load the player set"]
F["Viewer - 200 friends"] --> INT["Intersect"]
LOAD --> INT
INT --> COST["Millions of entries read to find at most 200"]
PAGE["A list of 30 games"] --> X30["x 30 - every page view"]The work scales with the game's popularity, which is backwards: the most-viewed games are the most expensive to render, and the answer is at most a handful of names.
Check each friend's membership instead
Flip the direction: for each of the viewer's friends, ask whether they recently played this game. That is friends × games lookups — 200 × 30 = 6,000 point reads against a fast store.
Now the cost depends on the viewer, not on the game, which is a much better shape. It is still six thousand lookups for one page render, and it degrades for people with large friend lists — exactly the users who see this line most often.
Fan out on write
When someone plays a game, push that fact into each of their friends' summaries:
%%{init: {"look":"handDrawn","handDrawnSeed":7,"theme":"base","fontFamily":"Virgil, \"Segoe Print\", \"Comic Sans MS\", cursive","themeVariables":{"fontFamily":"Virgil, \"Segoe Print\", \"Comic Sans MS\", cursive","fontSize":"16px","primaryColor":"#fff4e6","primaryBorderColor":"#1e1e1e","primaryTextColor":"#1e1e1e","secondaryColor":"#e7f5ff","tertiaryColor":"#ebfbee","lineColor":"#1e1e1e","textColor":"#1e1e1e","mainBkg":"#fff4e6","nodeBorder":"#1e1e1e","clusterBkg":"#f8f9fa","edgeLabelBackground":"#ffffff","classText":"#1e1e1e"}}}%%
flowchart LR
PLAY["Alex plays game 77"] --> FAN["Fan out to Alex's friends"]
FAN --> S1[("played_by[viewer1][77] += {Alex, ts}")]
FAN --> S2[("played_by[viewer2][77] += {Alex, ts}")]
READ["Viewer opens a game list"] --> GET["One read per game - already computed"]
S1 --> GET
GET --> LINE["'Played by Alex, Sam and 4 other friends'"]
CAP["Cap entries per (viewer, game); TTL for recency"] --> S1- The read becomes a single lookup per game, already in the shape the UI needs. Rendering is now cheap no matter how popular the game is.
- Write amplification is bounded by the friend count, and it happens off the request path where a few hundred milliseconds do not matter.
- Cap and expire. Keep only a handful of recent friends per
(viewer, game)with a TTL — the line shows three names and a count, so storing more is waste.
The case to handle explicitly is the very high-degree user: someone with tens of thousands of friends makes fan-out expensive on every play. Fall back to the pull path for those accounts, the same hybrid that feed systems use for celebrities. Saying which users get which strategy, and why, is the part that shows the trade-off was understood rather than memorised.
ArchitectureArchitecture
%%{init: {"look":"handDrawn","handDrawnSeed":7,"theme":"base","fontFamily":"Virgil, \"Segoe Print\", \"Comic Sans MS\", cursive","themeVariables":{"fontFamily":"Virgil, \"Segoe Print\", \"Comic Sans MS\", cursive","fontSize":"16px","primaryColor":"#fff4e6","primaryBorderColor":"#1e1e1e","primaryTextColor":"#1e1e1e","secondaryColor":"#e7f5ff","tertiaryColor":"#ebfbee","lineColor":"#1e1e1e","textColor":"#1e1e1e","mainBkg":"#fff4e6","nodeBorder":"#1e1e1e","clusterBkg":"#f8f9fa","edgeLabelBackground":"#ffffff","classText":"#1e1e1e"}}}%%
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.
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.
Wrap-UpWrap-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.