Blog

REAL-TIME SYSTEMS · PRESENCE · STATE MACHINES · GO

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.

anuragdevon 10th Nov 2026 12 min read Building Auragika · Part 2 of 5
01

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.

02

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:

Room phases: lobby, countdown, question, reveal, then the next question or finished, and back to the lobby for a rematch or a topic change.lobbycountdownquestionrevealfinishednext questionrematch or topic change = a new round, back in the lobbyhost needednobody neededhost needed

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.

03

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:

js — say goodbye properly, come back from the back/forward cache
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.

⚠ A PRESENCE BUG THAT WAS ALREADY LIVE

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.

04

Forfeit or play on?

Once you know someone left mid-match, what should happen? We decided differently by mode (ADR 0008):

ModeSomeone drops mid-matchWhy
1v1The other player can claim a forfeit winNobody is left to play against. A quick win beats ten questions alone.
GroupPlay 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.

05

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:

OptionWhy not (or why)
Rendezvous (highest-random-weight) hashingBuilt 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 hostTwo players asking at once would race.
Give it back when the old host returnsOn a flaky connection the room would change hands mid-conversation.
Next seat, in seat order, who is onlineEveryone 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.

// BORING ON PURPOSE

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.

06

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.

Two clients ask to hand over from host A. The first call makes B host. B's own late call says from A, but the host is now B, so it does nothing.Player CPlayer B (next)Game serverhand over, from: Ahost == A ✓→ host = Bhand over, from: A (late)host == A ✗→ no-opwithout "from", the late call looks like "B left" and hands the room to C

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.

07

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.

// REUSE THE GUARDS YOU HAVE

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.

08

How we test it

Every layer has its own tests, from pure rules up to six real browsers:

Pure rules (JS + Go)Next host, hand-over due, an offline player doesn't hold up a reveal, no group forfeit. Compared on 300 generated cases (26 due, 274 not)
Use cases (Go)Leave, take over, refusals (in a match, host present, no successor), races, events
Integration (emulators)Two concurrent claims change the host once; a new host leaving skips the offline old host
Rules (emulator)1v1 forfeit allowed, group forfeit refused
Browser (group of six)Host leaves the lobby → next seat hosts; that host drops mid-match → the match finishes → the next seat takes over at the results

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.

09

What's still open

  • Lost signal is still slow to notice. pagehide covers 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.

10

Takeaways

✅
Use the browser's goodbye
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.
✅
Find where an absent player still costs time
"Wait for everyone" and "wait for the leader" are both hidden dependencies on presence.
✅
Pick the succession rule people can guess
If you have a total order and a referee, you don't need a distributed algorithm.
✅
Requests carry the state they were based on
A from or round id turns a dangerous late request into a harmless no-op.
✅
Make new changes look like old, safe ones
A topic change became a new round and inherited every guard rounds already had.
Part 1
From a static quiz page to a game the client can't cheat
Part 2
Rooms that survive people leaving
This post.
Part 3
Testing a real-time app for real
Part 4
One team, five apps
Part 5
Bugs that erase data
END OF PART 2

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.

Contact