Rooms that survive
people leaving.
A multiplayer room is easy while everyone stays. The hard part is the player who closes the tab, loses signal in a lift, or was the host. This is how QuizClash learned to notice a dropped player in seconds instead of a minute, keep a group game going without its host, and hand the room over exactly once, even when every client asks at the same moment.
The symptom: 90-second matches
In Part 1 we moved the game onto a Go server: clients ask for each step when it's due, and the server takes it once. That fixed cheating. It didn't fix people.
A group match of ten questions normally takes about 50 seconds. Once a player closed their tab, especially the host, the same match took about 90. Nothing crashed. Every question just waited a little too long before it revealed, and those waits added up.
Before that, it was worse. A group host who went offline mid-match ended the match for everyone, as a forfeit, and between matches only the host could start, rematch or change the topic. If the host left the lobby, the room was dead and everyone had to make a new one and share a new code.
Groups had also started staying in one room for match after match. The host had quietly become a single point of failure for the whole evening.
A room's life, as a state machine
It helps to see what "leaving" can interrupt. A room moves through a small set of phases, and the server owns every transition:
The host only matters between matches. Mid-match, any client's request moves the round along.
That last observation became the core of the design. Once the server runs the round (Part 1), nothing mid-match actually needs a host. The host is needed in exactly two places: the lobby (start, change topic, remove a player) and the results (rematch). So there are two different problems: a player leaving mid-match, and a host missing between matches.
Noticing that someone left
Everything starts with presence: each player writes their own {online, lastSeen} entry, and an onDisconnect handler on the Realtime Database flips it to offline when the connection dies. The catch is when it dies. Firebase notices a dead socket only after its own timeout, which can be up to a minute. Until then, the other players see someone "online" who isn't there, and wait for their answer.
When a person closes a tab or navigates away, though, the browser tells you. The fix in PR #32 uses that:
addEventListener('pagehide', () => { presence.markOffline(); // write "offline" now, not in a minute db.goOffline(); // close the socket cleanly }); addEventListener('pageshow', (e) => { if (!e.persisted) return; // restored from the back/forward cache db.goOnline(); presence.markOnline(); });
pagehide rather than beforeunload or unload: it fires reliably on mobile, and it doesn't stop the page from entering the back/forward cache. That's also why the pageshow half exists. A page restored from that cache never reloaded, so without it a player who pressed Back would be in the room but marked offline.
This only covers people who leave. Someone who loses signal still waits for Firebase's timeout. We wrote that down as a known limit instead of pretending otherwise.
When we first ran the browser tests against the real database rules, presence turned out never to have worked: onDisconnect().update() on an entry that didn't exist yet was refused by the rules, so nobody's online status was ever set up. The fix was a one-word change to set(). Only a test against the real rules could have found it.
Forfeit or play on?
Once you know someone left mid-match, what should happen? We decided differently by mode (ADR 0008):
| Mode | Someone drops mid-match | Why |
|---|---|---|
| 1v1 | The other player can claim a forfeit win | Nobody is left to play against. A quick win beats ten questions alone. |
| Group | Play on. The absent player stops scoring. | Five people are still playing. One leaving shouldn't end it for them. |
"Play on" sounds simple, but the 90-second matches came from two hidden places where an absent player still cost everyone time:
- The reveal waited for them. A question reveals early when everyone has answered. An absent player never answers, so the question ran to the full timer. The fix: an offline player doesn't count toward "everyone answered". That rule is a pure function, so it lives in the JavaScript
core/and its Go port, compared by the shared fixtures. - The grace period was paid twice per question. Each step is requested by the host first, and by everyone else after a short grace. With the host gone, every reveal and every next question waited for that grace. The fix: while the host is offline, the next host in line drives the round straight away.
We also considered and rejected a bot that plays the host's turns. There's nothing to play: the server already runs the round, and a bot's answers would distort scores and records.
Who becomes host
Between matches the room does need a host. When the current one leaves or stays offline too long, someone has to take over. This is a small leader-election problem, and it's tempting to reach for a known algorithm. We wrote down the ones we considered:
| Option | Why not (or why) |
|---|---|
| Rendezvous (highest-random-weight) hashing | Built for spreading many keys over many nodes with minimal reshuffling. We have one decision per room among at most eight players who already have an order. A hash would only make "who's next" harder to explain. |
| Client consensus (Raft, Bully) | For peers that must agree without a referee. We have one: a database transaction. |
| Whoever asks first becomes host | Two players asking at once would race. |
| Give it back when the old host returns | On a flaky connection the room would change hands mid-conversation. |
| Next seat, in seat order, who is online | Everyone can predict it, the server computes it, and it cycles if that player leaves too. Chosen. |
Two hand-over paths fall out of this. A host who taps Leave room hands over at once and gives up their place. A host who simply goes quiet is replaced after a timeout; they keep the successor's old seat, so they can come back as a player, but they don't get the room back.
The best succession rule is the one every player could guess. "The next person in the seat list" needs no explanation in the UI. The toast just says who left and who's hosting now.
Handing over exactly once
Every client sees the host go quiet at about the same time, so every client may ask the server to hand over. The successor asks straight away; the others after a grace. The server picks the successor itself (never "the caller") and swaps the host in one compare-and-set transaction that also checks the round and phase haven't changed. One call lands; the others find nothing to do.
The browser test found a subtler race. The new host's own takeover request could arrive after it had already been made host by someone else's call. To the server, that request looked like "the host has left" and it handed the room on again, to the third player.
The fix is one field. Every hand-over request says which host the caller saw: from. If the room's host is no longer from, the request does nothing.
Name the state you acted on. The server ignores requests about a world that no longer exists.
It's the same idea as the compare-and-set on phases in Part 1, applied to a request instead of a write: a request carries the version of the world it was based on, and stale requests are no-ops, not errors.
Leaving had one more ordering detail. A host who taps Leave writes "offline" before the hand-over, so the old host never tries to write presence as someone who is no longer a member, which the rules would refuse.
Changing topic in a live lobby
Once groups stayed in one room, they wanted to change topic without making a new room (ADR 0005). The tempting implementation is to update the room's topic and level in place. We didn't, because of what can arrive late:
- A late result. A slow phone's "finish" for the last match arrives after the topic changed. If the topic changed in place, that finish could be scored against the new topic's answer keys and recorded under the new topic's high scores.
- A start racing a change. If the host's start and a topic change cross, the room could start with the new questions and the old ready flags.
So a topic change is a new round. Everything a round leaves behind is keyed by its round id, and every request names its round. The server draws fresh questions, moves the room to the next round (still in the lobby) through the same compare-and-set path as a rematch, and a late finish gets a clear round_mismatch. Only the host starts ready; everyone else readies up again, so nobody plays a topic they didn't agree to.
We didn't add a new safety mechanism for topic changes. We made the change look like something the system already handled safely (a rematch) and inherited every guard that came with it.
How we test it
Every layer has its own tests, from pure rules up to six real browsers:
The most important change in the browser test was what it waits for. The original check asserted timing ("the reveal happens within N seconds of a player dropping") and was flaky enough that we quarantined it (issue #2). The replacement asserts state: the dropped host shows offline, and the next host is the one driving the round. That check has been stable since. How we got there is the subject of Part 3.
The result: the dropped-host group match went from about 90 seconds back to about 50, and the test asserts the second match finishes well under the old time.
What's still open
- Lost signal is still slow to notice.
pagehidecovers people who leave, not phones that go into a tunnel. Those still wait for the database's own timeout. - A host alone can't hand over. There's nobody to hand to; the room is left for the scheduled clean-up.
- An old host who never returns keeps a seat. They show as offline, and the new host can remove them in the lobby like any absent player.
Each of these is written in the ADR's consequences, so the next change starts from what's known rather than rediscovering it.
Takeaways
pagehide to mark offline and close the socket, pageshow with persisted to come back. Don't wait for a socket timeout to learn what the page already told you.from or round id turns a dangerous late request into a harmless no-op.Try leaving a room
Start a group room on QuizClash, then close the host's tab mid-match. The game should carry on, and the next seat should pick up the room afterwards.