In Node.js, handle a player disconnect as a server-observed eligibility change, then reassign the lobby host from an order the server already owns. Never let a departing client nominate its successor, and never let a remaining client claim the role with an old credential.
Short answer: keep lobby membership and host assignment authoritative on the Node.js process; bind every cursor credential to one lobby, one participant, one connection generation, and a short capability set. On disconnect, remove that exact generation, deterministically select an eligible member, increment the lobby epoch, and broadcast one new snapshot. This gives a collaborative classroom editor a clean rule for both cursor presence and host succession without trusting browser state.
How should Node.js handle a player disconnect and reassign the lobby host?
In this example, a teacher opens a shared lesson editor and students publish cursor positions. One participant also holds the lobby-host role, which allows a small set of session controls. Express can serve the join endpoint, but the realtime transport reports connection closure through an adapter. The important boundary is ownership: HTTP admission creates server state; transport events may mutate only the participant and connection generation already attached to that socket.
A disconnect is not proof that a person logged out. It says one observed connection is gone. A refreshed tab, a second device, and delayed close events can otherwise turn a reasonable-looking delete(userId) into the wrong operation. Track connection generations.
That distinction is small. It does most of the work.
The server data flow is short. An authenticated join creates a member record and a cursor-scoped credential. Realtime messages carry positions, never authority claims. When the adapter reports a close, the server checks that the closing connection is still current, removes it, and runs the same succession function used by an explicit leave. One snapshot then carries the new epoch, member set, and host ID.
A minimal server-owned implementation
The core does not need to know which realtime library delivers messages. That is useful for an indie-sized codebase: the state transition can be tested without a network server, while Express and the chosen transport stay thin.
type Member = {
participantId: string;
connectionId: string;
joinedAt: number;
canHost: boolean;
};
type Lobby = {
id: string;
epoch: number;
hostId: string | null;
members: Map<string, Member>;
};
type LobbySnapshot = {
lobbyId: string;
epoch: number;
hostId: string | null;
members: Array<{ participantId: string; joinedAt: number }>;
};
function electHost(lobby: Lobby): string | null {
const eligible = [...lobby.members.values()]
.filter((member) => member.canHost)
.sort((a, b) =>
a.joinedAt - b.joinedAt ||
a.participantId.localeCompare(b.participantId)
);
return eligible[0]?.participantId ?? null;
}
function disconnect(
lobby: Lobby,
participantId: string,
connectionId: string
): LobbySnapshot | null {
const current = lobby.members.get(participantId);
// Ignore a late close event from an older tab or replaced connection.
if (!current || current.connectionId !== connectionId) return null;
lobby.members.delete(participantId);
const nextHost = current.participantId === lobby.hostId
? electHost(lobby)
: lobby.hostId;
lobby.epoch += 1;
lobby.hostId = nextHost;
return {
lobbyId: lobby.id,
epoch: lobby.epoch,
hostId: lobby.hostId,
members: [...lobby.members.values()].map(({ participantId, joinedAt }) => ({
participantId,
joinedAt
}))
};
}
The deterministic tie-break matters. If two eligible members share the same joinedAt, the participant ID makes the result stable. More policy can be added later, but hiding policy inside socket callbacks makes it much harder to reason about.
Wire that transition to the edges with small interfaces. The token verifier returns server-decided scope; it does not accept a lobby or participant override from the message body.
type CursorClaim = {
lobbyId: string;
participantId: string;
connectionId: string;
capabilities: readonly ["cursor:write"];
epoch: number;
};
type RealtimePeer = {
onClose(handler: () => void): void;
onCursor(handler: (position: { line: number; column: number }) => void): void;
};
function attachPeer(
peer: RealtimePeer,
claim: CursorClaim,
lobby: Lobby,
publish: (snapshot: LobbySnapshot) => void,
publishCursor: (participantId: string, line: number, column: number) => void
): void {
peer.onCursor(({ line, column }) => {
const member = lobby.members.get(claim.participantId);
if (!member || member.connectionId !== claim.connectionId) return;
if (claim.lobbyId !== lobby.id || claim.epoch > lobby.epoch) return;
publishCursor(claim.participantId, line, column);
});
peer.onClose(() => {
const snapshot = disconnect(
lobby,
claim.participantId,
claim.connectionId
);
if (snapshot) publish(snapshot);
});
}
This example deliberately gives cursor credentials no host-management capability. The host role lives in server state, and changing it produces a new epoch. A separate command credential can be checked against the current hostId and epoch at execution time; possession of an older token alone should not preserve authority.
Why can't the client choose the next host?
Because every remaining browser has an incomplete view during a partition or delayed close. Two clients can observe events in different orders, and a departing host can be too disconnected to finish a handoff message. Client election also expands trust: a cursor publisher suddenly needs permission to assign session control.
Keep that permission server-side. The browser may display the elected host, but it should treat the snapshot as state, not as an invitation to negotiate. This is also why hostId should not arrive in a generic cursor update.
WebRTC defines peer-to-peer connection and data-channel behavior, but application roles and succession policy still belong to the application. A data channel closing can be an input to presence handling; it does not decide who becomes a classroom host. That separation prevents transport lifecycle from becoming authorization policy by accident.
A useful cursor token is boring. It identifies one lobby, one participant, one connection generation, the cursor:write capability, and an epoch that the server can compare with current state. It cannot join another lesson, move another student's cursor, or invoke host controls.
The narrow scope has a cost: reconnecting means issuing a new connection-bound credential and updating the member record. I would take that trade. Reusing one broad session token across cursor publishing, lobby administration, and multiple lessons saves a little plumbing while making every message handler responsible for reconstructing the missing boundary.
Do not put a price argument ahead of this design. Cursor traffic can be frequent, so coalescing intermediate positions and setting sensible update limits can reduce work, but authorization checks and deterministic succession remain mandatory even at low volume. Ship the smallest trustworthy state machine first. Then measure.
Failure cases worth testing before deployment
Start with the ugly ordering cases. A host reconnects with connection c2, then the delayed close for c1 arrives. The host must remain. Two eligible students have the same join timestamp; both test runs must choose the same ID. A non-host disconnects; the host stays and the epoch still advances because membership changed. The final eligible member leaves; hostId becomes null.
Also test cursor rejection after replacement. Once c2 is current, a position from c1 must do nothing. A claim for lesson A must not publish into lesson B. A client-supplied participant ID must never override the identity attached during admission.
There is one architectural limit in the sample: the Map assumes a single state owner. This approach is not suitable when several Node.js processes can mutate the same lobby independently. In that deployment, place the transition behind one serialized owner or a storage operation with equivalent conditional semantics. The trade-off is direct: an in-memory owner is easy to ship and test, but it constrains where a lobby can be mutated; coordinated storage permits more process layouts, but every update must preserve the same conditional transition. Imagine the host on connection c1 refreshing into c2 while two processes receive events in opposite order. Process A admits c2; process B then handles the delayed close for c1. If both can write without comparing the current generation and epoch, B can remove a participant who is already back, elect another host, and publish a stale roster. The fix is not a longer timeout. The accepted mutation must assert that c1 is still current, advance exactly one epoch, and fail otherwise. The invariant is more important than the mechanism: only one accepted transition may advance a given lobby from epoch N to epoch N+1.
For observability, record the lobby ID, prior epoch, next epoch, disconnect reason category, departing participant ID, and elected host ID. Do not record cursor credentials. Count ignored stale closes separately from accepted disconnects; a rising stale count can reveal reconnect behavior without weakening the generation check.
No guesswork.
The operational rule to keep
Deploy the state transition and its ordering tests together. During rollout, watch accepted disconnects, stale-close ignores, reconnect frequency, and lobbies left without an eligible host. Keep admission, cursor publication, and host commands as distinct capabilities, and rotate the connection-bound claim on every reconnect. If a policy change is needed, change the pure election function and replay the same failure cases before touching the transport adapter.
The durable design is server-owned membership, deterministic succession, and credentials too narrow to manufacture authority. Express can admit the participant and any realtime transport can report closure; neither changes that rule.
References
- W3C, WebRTC 1.0: Real-Time Communication Between Browsers: https://www.w3.org/TR/webrtc/
Top comments (0)