DEV Community

ValdemarBlack3817
ValdemarBlack3817

Posted on

Lobby Host Succession Explained: 4 Rules After a Player Disconnect

The hard trade-off is speed versus truth: reassigning a lobby host on the first broken socket feels responsive, but it can create two hosts during a brief network interruption. Treat the socket as a signal, not as membership state. For a multiplayer-style shared workspace, keep an authoritative lease for each player, expire that lease after a bounded grace period, and choose the successor with a deterministic rule inside one atomic state transition. Node.js and Express can receive the events, but the database must decide who owns the lobby.

Short answer: record a monotonically increasing connection generation, refresh a server-owned lease while the player is healthy, and run host reassignment only if the disconnect still refers to the current generation. In a four-person workspace, this prevents an old connection's late close event from evicting the same player after a reconnect. Publish the committed lobby revision afterward; clients never elect the host themselves.

How should Node.js handle a player disconnect in the lobby?

A close event says that one transport ended. It does not prove that a person left the workspace. A laptop can move between networks, a browser can open a replacement connection before the old close reaches the server, and a process can disappear without executing its cleanup callback. WebRTC exposes connection-state changes, but those states still describe a peer connection rather than application membership. The distinction matters whether the realtime leg uses WebRTC data channels or a server socket.

I use the same mental model that keeps OTP delivery sane: acceptance by one hop is not proof of the final business outcome. Here, transport closure is not proof of absence. The application needs its own evidence and deadline.

Wait for application evidence.

Give every successful join a connection_generation allocated by the server. Store lease_expires_at and a stable joined_sequence beside the member. A heartbeat may extend only the matching generation. Likewise, a disconnect may shorten or mark only that generation. If generation 18 reconnects as generation 19 before a delayed close for 18 is handled, the comparison turns the old event into a harmless no-op.

The stale event loses.

The grace window should be a product decision backed by measurements, not a magic universal constant. A short window reduces leaderless time but misclassifies more transient gaps. A long one preserves membership through rough networks but delays a necessary handoff. Measure heartbeat lateness and reconnect duration by client class, then set a bounded window that matches what the workspace can tolerate. Do not let clients choose it.

Four invariants make the handoff predictable

The design becomes easier to test when written as invariants rather than callback order:

  1. A member is online only while the server-owned lease is valid for the latest connection generation.
  2. At most one active member owns the host role for a lobby revision.
  3. The successor is the eligible member with the lowest stable joined_sequence; a player ID breaks a tie.
  4. Every committed membership or host change increments lobby_revision, and consumers ignore older revisions.

The third rule is intentionally boring. Random selection complicates replay, while client voting adds another distributed protocol to the failure path. A stable ordering lets two engineers inspect the same snapshot and predict the same result. You could instead encode moderators, roles, or workload in the ordering, but eligibility and tie-breaking must still be explicit.

Boring is useful here.

There is an important edge case: the expired player may not be the host. That transaction removes or marks the member offline and increments the revision, but it does not churn leadership. Another one: no eligible members remain. The correct host is then null, not the disconnected player's stale identifier. Empty is a state.

Keep presence transitions separate from authorization. A newly selected host may gain lobby-management privileges, so the command path must read current server state rather than trusting a role cached in a token or UI. This is the compliance-shaped part of the design: retain only the identifiers and timing evidence needed to resolve membership, bound the retention of connection logs, and avoid turning presence telemetry into an accidental activity history.

Put the decision in one transaction

Express should authenticate the connection and route join, heartbeat, and close signals. A worker or timer should process expired leases. Both paths call the same repository operation; neither performs a read, decides in memory, and writes later. That split creates a race between concurrent disconnects.

The following Python reference shows the transaction boundary without tying it to a database library. The SQL repository is expected to lock the lobby row, reject a stale generation, choose from unexpired members, and commit the new revision atomically. In a Node.js service, the equivalent belongs in one awaited database transaction called from the socket handler or expiry worker, not in an Express route-local map.

from dataclasses import dataclass
from datetime import datetime
from typing import Protocol


@dataclass(frozen=True)
class Handoff:
    lobby_id: str
    revision: int
    host_player_id: str | None
    changed: bool


class PresenceRepository(Protocol):
    def expire_generation_and_reassign(
        self,
        *,
        lobby_id: str,
        player_id: str,
        connection_generation: int,
        observed_at: datetime,
    ) -> Handoff:
        ...


def handle_expired_lease(
    repository: PresenceRepository,
    *,
    lobby_id: str,
    player_id: str,
    connection_generation: int,
    observed_at: datetime,
) -> Handoff:
    return repository.expire_generation_and_reassign(
        lobby_id=lobby_id,
        player_id=player_id,
        connection_generation=connection_generation,
        observed_at=observed_at,
    )
Enter fullscreen mode Exit fullscreen mode

The small handler is deliberate. Correctness lives in the repository contract. Within the transaction, compare the supplied generation with the member's current generation. If it is stale, return the current revision unchanged. If the current lease has not expired at observed_at, also do nothing. Otherwise mark that generation offline; if it owned the host role, select the first eligible member by (joined_sequence, player_id), update the host, increment the revision, and commit.

Publish only after commit. Delivery can be retried, so the event carries the resulting revision and full authoritative host ID. A client that has revision 42 discards revision 41 and applies revision 43. If the publisher fails after the database commit, an outbox record written in that same transaction provides a durable retry point. The realtime message is then a notification to fetch or apply state, not the sole copy of the decision.

What should the failure tests prove?

A happy-path disconnect test is weak evidence. The useful suite controls time and ordering. Start with a lobby containing four pseudonymous players: p-17 is host at revision 8, while p-23, p-41, and p-62 joined in that order. Expire p-17 and assert that p-23 becomes host at revision 9. Then make the timing hostile.

Now break it.

Test a reconnect that advances the generation before the old close is processed. The old close must change nothing. Fire two expiry jobs for the host concurrently; exactly one revision should commit. Expire a non-host and confirm that leadership stays put. Expire everyone and confirm a null host. Replay the same expiry request and expect the same visible state without another revision. Finally, deliver revision events out of order and verify that the client never moves backward.

Also kill the application process rather than asking it to close politely. The lease must expire without a close callback. Pause the expiry worker, let several leases become due, restart it, and verify deterministic ordering under the backlog. These cases are more valuable than a large synthetic connection count because they attack the ambiguity that causes duplicate hosts.

Observe decisions, not private activity. Useful counters include lease expirations, stale-generation rejections, host changes, and time spent without an eligible host. A structured decision log can include lobby ID, old and new revision, reason, and hashed or pseudonymous member identifiers. Avoid raw IP addresses or message content in routine presence logs; define retention before deployment.

Roll out without changing every client at once

First, add generations, leases, and revisions while the existing host logic remains authoritative. Run the new decision function in shadow mode and compare its proposed host with the current result, without publishing the proposal. Differences need classification: stale close, ordering disagreement, missing heartbeat, or an implementation defect.

Next, enable server-authoritative handoff for a small lobby cohort and keep a rollback switch that changes the decision path, not the stored revision history. Clients should already tolerate a null host and ignore older revisions. Expand only after stale-generation rejections and leaderless duration look explainable.

That is the durable pattern for a Node.js/Express lobby as well as a shared B2B workspace: transports report evidence, leases define online status, one transaction assigns leadership, and revisions make delivery order survivable. Framework callbacks are wiring. Presence accuracy comes from the state machine behind them.

Sources

Top comments (0)