Docs

What Hearso is, why it exists, and how it is put together. Written for people who want the real shape of it rather than a feature list.

Last revised 2026-09-24.

What it does

Hearso runs one short game a day for teams that do not share an office. Someone starts a room, everyone joins with a code, and a few questions later there is a score and something to talk about. A round takes about five minutes. Nobody has to install anything, and players without an account can join with a code.

It started as a trivia game and is becoming a library of them. The scores are kept on a leaderboard that is deliberately a separate service, because the games are meant to change and the record of who played should not have to change with them.

Why we built it

Remote teams lose the small talk that used to happen by accident. The usual fix is a scheduled call where people take turns being cheerful, which most teams quietly stop doing after a month.

So the constraint we designed against was time. A round is short enough that nobody has to protect it in their calendar, and async by default so people in different hours are not excluded. The point is not the trivia. It is that the squad has one thing in common each day that is not work.

How it works

A browser talks to a Next.js app on Vercel. The app owns rooms, players, questions and profiles, and keeps them in Postgres. When a round finishes, the scores go to a separate leaderboard service that the app reaches over HTTP.

A request goes from a browser to Vercel's edge, into a page rendered on the server, which reads Postgres on Supabase and returns finished HTML.
How a browser reaches a room

Four boundaries are worth holding in mind, because almost every question about this system turns out to be a question about which side of one of them you are on. The web app decides who may enter a room. A realtime service written in Rust runs the live game. Postgres holds anything that has to survive a restart. And a set of consumers act on a finished game only after it has been committed, never during.

There are two ways a room can run. Today every room is polled: the browser asks the app for the room's state a few times a second, and the app answers from Postgres. It is unglamorous and it works. The realtime service is the second way, and it is built and running but not yet what production serves. The move is deliberate and unfinished, and this page would rather say so than imply otherwise.

A round, while it is being played

A room is only ever in one of four states, and most of a game is the middle two repeating until the deck runs out.

A room moves from lobby to question to reveal, loops back for the next question, and ends at finished.
The four states a room moves through
Getting onto the socket
A browser never connects to the realtime service on its own say-so. The web app admits you first, then issues a short-lived ticket. The realtime service checks that ticket and spends it, so a copied one has already been used by the time anyone else tries it.
One room, one actor
Each live room is a single thing held in memory. The first person to connect starts it and everyone after joins the one already running, which is why a new player can appear without anything new being started.
Everyone gets their own view
The room sends each player a picture of what it looks like to them, because the host can see things a player cannot. The reply to your own action is a separate message from the picture everyone receives.
Two clocks, on purpose
An answer counts for the first ten seconds. The question closes at twelve. In between is a moment where the question is still on screen and an answer is already too late: deliberate, because an answer that silently scored nothing would be worse than one that is plainly refused.

A finished room is not a dead one. If the players vote for a rematch it returns to the lobby as a new round on the same code, which is why the code someone shared at the start of the afternoon still works at the end of it.

The moment a round ends

This is the part with the most care in it, because it is where losing something would actually matter. Ending a game writes several things at once: the room is marked done, the result is recorded, who played is recorded, and a record is appended for the work that happens afterwards.

All of it happens in one database transaction. Either the game finished and every one of those is true, or none of them is and the room can be finished again. There is no state in which the result and the record of who played disagree with each other.

Only once that has committed is anything else told about it. The realtime service then notifies the web app, which works out achievements. If that message never arrives the result is not at risk: a periodic sweep finds anything still unprocessed and picks it up. A delivery failure cannot undo a game.

The obvious shortcut, publish to the message broker from inside the game itself, does not work, and it is worth saying why. The database and the broker do not share a commit, so you are only choosing which way to be wrong: a game that finished and a message that never went, or a message about a game that rolled back. Writing the record in the same transaction as the result removes the choice. Everything downstream reads from that record, at its own pace, and can be replayed from it.

How a score reaches the leaderboard

A finished round writes a queue row in the same transaction. The row is drained to an event log on Kafka and on to the leaderboard on AWS.
How a finished round reaches the leaderboard
The record is the promise
Written in the transaction that ended the round. Everything past this point is a reader, and a reader that falls behind catches up rather than losing anything.
The event log is a view of it
A relay reads those records in order and publishes them. The log can be rebuilt from the database at any time, which makes losing it an inconvenience rather than an incident.
Delivered at least once, on purpose
Every submission carries a key built from the game, the room, the round and the player, so sending it twice is the same as sending it once. That lets the system be relaxed about duplicates and strict about loss, which is the right way round.
Refusals are kept, not dropped
A submission the leaderboard refuses goes to a dead-letter stream with the reason attached, rather than disappearing into a log line nobody reads.

The leaderboard itself never sorts at read time. Scores are written into keys that are already in rank order, so reading the top of a board is a range query rather than a sort. It keeps the best score per player per board, which is also what makes a replayed message harmless: a worse score arriving late cannot take someone's place.

Those records carry pseudonymous player identifiers, so they are deleted about a week after they have been handled. That is a privacy decision with an operational consequence worth naming rather than discovering: it puts a limit on how far behind a reader can fall before replaying it stops being possible, which is why how far behind they are is a thing we watch.

The services, and what each one owns

Four services, each with one job. They are separate because they fail differently and change at different speeds, not because separation is fashionable.

The browser talks to the web app on Vercel, which reads Postgres on Supabase and submits scores to the leaderboard on AWS. A Rust realtime service is built but not yet in production.
The four services and what each one owns
The web app: Next.js on Vercel
Rooms, questions, profiles, squads and the pages you are reading. It decides who may enter a room, and it is the only service a browser talks to for game state today.
The realtime service: Rust
Runs a live room in memory and pushes each player their own view of it. It owns the game while it is being played, and the transaction that ends it. Built, running, and not yet serving production rooms.
The leaderboard: AWS
API Gateway in front of Lambda, with DynamoDB behind it. It has no accounts of its own by design: the calling game vouches for its players, which is why scores are submitted from our backend and never from a browser.
The event log: Kafka
Carries finished games from the database to the leaderboard, with a dead-letter stream for anything refused. Also built and not yet in production.

One consequence is worth stating plainly, because it is the kind of thing a diagram hides: the web app and the realtime service are not interchangeable. Admission belongs to one and gameplay to the other, and which way a room runs is fixed when the room is created rather than negotiated later.

How we know it is working

A public status page, rendered by us from an Uptime Kuma instance rather than embedded from it, so an outage of the status page cannot take the product's page down with it.

Behind that, the usual three: logs, metrics and traces, collected into Grafana. This is the least finished part of the system and it is worth being plain about that. The realtime service writes structured logs today and emits no metrics or traces yet, so it can tell you how many rooms failed but not which one.

That gap is the direct cost of a decision we would take again: those logs carry no room code, no player identifier and no answer, so they are safe to send anywhere. The fix is not to put identifiers back: it is an identifier that means nothing outside our own systems, so a room can be followed without a person being. It is on the roadmap, rather than described here as though it were done.

What we use, and who we send data to

The whole list. If a name is not here, we do not send it anything.

Vercel
Hosts and runs the web app.
Supabase
Postgres, and the accounts system. Rooms, players, questions and profiles live here.
Amazon Web Services
The leaderboard: API Gateway, Lambda, DynamoDB, and CloudWatch for its alarms.
Cloudflare Turnstile
Checks that a sign-up is a person. It sees the challenge, not your account.
Resend
Sends the few emails we send: sign-in links and replies.
PostHog
Product analytics, and only after you have said yes to it. Guests who never consent are never counted.
Grafana Cloud
Where logs and metrics go, for us to read. No player data.
Hostinger
A small server that runs the realtime service.

If you want to go further

The roadmap
What is being built, including the parts of the above that are not finished. It is honest about ordering rather than promising dates.
The changelog
What actually shipped, release by release. Deliberately a different document from the roadmap.
Security
How accounts, sessions and player data are handled.
The status page
Whether it is up, right now, and what broke recently.
HearsoHEARSO · LOADING

Loading, 0%

Never goes backwards. Never lies about being done. Under a second on a good day.

ASSETS · STATE · HANDSHAKE
Help improve Hearso

With your permission, we measure how you use Hearso (pages, buttons, your display name, your squad's name and your games) and record sessions and click heatmaps of how pages are used. We never send your first name, last name, email, phone number or passwords to analytics, and recordings hide them. You can change this in Settings after signing in. Hearso also keeps anonymous totals of rounds, players, and live rooms without this permission; see the player guide.