Skip to content
How it is built

The friend list my server never sees

By Ari Hlynsson ·

A green dot telling you which friends are online, drawn by a server with no database connection and no way to learn who your friends are.

If you add a friend on this site, a green dot tells you when they are online and lets you challenge them to a game. The server drawing that dot does not know who your friends are and has no way to find out. I built it that way to avoid paying for a second service, and the privacy property came along with the decision.

Three places presence could have lived

Presence could live in the database, with every client writing a heartbeat row every few seconds and a query for the recent ones. That works, and it is a continuous stream of writes for data that is worthless thirty seconds later, on a free tier I would rather spend on things people keep.

I also considered Redis, which my own roadmap recommended. Redis is a second always-on service with a monthly bill, for a green dot.

I chose the game server's memory. A process is already holding open WebSocket connections to every player at a table, so it already knows which players are connected. Presence is a Map next to the rooms. It needs no extra service, and after a restart the clients rebuild it within seconds by sending their account details when they reconnect.

How the server gets the friend list

The game server has no database connection and never has. It referees card games, and giving it credentials to the table holding the friend graph would be a larger risk than this feature is worth.

That leaves a problem. Telling you which friends are online requires knowing who your friends are, and the only component that knows is your browser, which reads the list from a table only your own account can read.

So the client brings it. On connecting, your browser sends the server the list it has just read for itself, and the server keeps it in memory beside your connection.

At first I thought this could not be safe. If a client declares its own friend list, a client can declare anybody's.

The rule that makes it work

Presence is reported only where two lists agree.

for (const friendId of entry.friends) {
  const friend = this.entries.get(friendId);
  // The mutual test. One-sided interest tells you nothing at all.
  if (!friend || !friend.friends.has(userId)) continue;
  online.push({ id: friendId, status: isPlaying(friend) ? "playing" : "online" });
}

Claiming somebody as a friend gets you nothing. They have to have claimed you, in the list their own browser sent, read from a table only they can read. The answer to "is this account online" never goes to somebody that account has not also named, which is what stops the server being a lookup service for anybody holding an account id.

Account ids are not public either. The leaderboards publish md5(user_id) instead of the id, so a public board does not hand out the input to this.

Challenges use the same rule for a stronger reason. Leaking your presence would tell somebody you are online; a challenge from a stranger would put an unsolicited link on your screen. Both sides must have named each other, and the target has to be connected at that moment. There is no queue, because a challenge to an empty chair is stale before anybody reads it.

What it does not do

The mutual check compares two submitted lists. It does not verify the identity behind either of them, so a client that lies about which account it is can make that person appear online to their own friends.

Doing so requires two account ids that are only visible to friends in the first place, and it reveals nothing to the person doing it. Verifying a Supabase token on the game server would add the missing identity check, at the cost of putting the project's signing secret on a machine that currently holds no secrets. For now I have left presence without token verification.

There is a structural limit as well. This works because there is one server process. If I ever split the game server across two, the design stops working and Redis becomes the right answer after all. The changes needed for that would be confined to one class, which is why I am comfortable with it.

The same question, asked about a score

Two friends who play each other build up a record of their games. Each player's client writes that player's copy into rows only their own account can read.

My copy and your copy might both say I have beaten you four times, and each total is reported separately by a client. Nothing reconciles them. If you edited yours, mine would not notice.

I allow that on purpose. A score reported by a player's own client is adequate for showing one person their history with one friend, and I do not use it for anything public. The leaderboards are written by a different path entirely, with a single trusted writer and a cap on every value.

The two features needed different checks because a private record and a public ranking are exposed to different people. Working out which one you are building comes before choosing how to defend it.

More on how it works

  • The chat box I did not build

    Eight preset phrases, sent as an index rather than as text, so free chat is unrepresentable on the wire rather than merely refused.

  • One puzzle for the whole world, no database

    Everybody gets the same daily board and nothing is stored anywhere. Two functions turn today's date into a seed, and each browser generates the puzzle for itself.

Play a game instead