Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,10 @@ Minimal WebSocket relay server for party games. Clients share rooms and exchange

- A client **creates** a room (server assigns a 6-char code) with a max client limit
- Other clients **join** by room code
- Each member is assigned a numeric `index` (slot id). Indices are stable for the room's lifetime and never reassigned
- Each member is assigned a numeric `index` (slot id). Indices are stable for the room's lifetime and never reassigned. New joiners always get the next index, so in long-lived rooms with churn an index can exceed `maxClients`
- Clients pick their own `clientId`; it stays server-side and acts as the bearer secret for their slot. Reconnecting with the same `clientId` replaces the old connection in the same slot
- Messages can be **broadcast** to all peers or **sent** to a specific peer index
- `peer_left` is broadcast immediately on disconnect
- `peer_left` is broadcast immediately on disconnect, and the cap slot frees right away — a new joiner can take it. The original `clientId` can still reclaim its old index if no one took the spot in the meantime
- Rooms are cleaned up when empty

## Run
Expand Down Expand Up @@ -77,7 +77,7 @@ ws.onmessage = (event) => {

Joining with the same `clientId` replaces the old connection in the same slot; no special reconnect message needed. The server closes the previous WebSocket with code `4000` and reason `"replaced"`. Treat that as terminal in your reconnect loop, otherwise the new connection will be torn down by the next replacement.

Since the `clientId` stays on the original client, another connection can't claim your slot; they'd just get a fresh index. Persist it locally if you want reconnect to survive a refresh.
Since the `clientId` stays on the original client, another connection can't *take over* your slot — but they can fill the cap spot it freed when you disconnected. Reconnect is best-effort: if the room re-filled while you were gone you'll get `Room is full`. Persist `clientId` locally if you want reconnect to survive a refresh.

Hosts reconnect like guests: send `join` with the original `clientId` and room code, not another `create`.

Expand Down
43 changes: 38 additions & 5 deletions server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -386,7 +386,10 @@ describe("reconnect", () => {
expect(rooms.get(room)?.active).toBe(2);
});

test("new clientId cannot consume a disconnected owned slot", async () => {
test("disconnected slot frees the cap for a new joiner at the next index", async () => {
// Cap reflects live clients. When guest disconnects (battery, network,
// tab closed — not just intentional), a stranger can take the spot.
// Indices are still never reassigned: the stranger gets index 2, not 1.
const ws1 = track(await connect());
sendMsg(ws1, { type: "create", clientId: "host", maxClients: 2 });
const { room } = await waitForType(ws1, "created");
Expand All @@ -401,11 +404,41 @@ describe("reconnect", () => {

const ws3 = track(await connect());
sendMsg(ws3, { type: "join", clientId: "stranger", room });
const msg = await waitForType(ws3, "error");
const joinMsg = await waitForType(ws3, "joined");

expect(joinMsg.index).toBe(2);
expect(rooms.get(room)?.active).toBe(2);
expect(rooms.get(room)?.members).toHaveLength(3);
});

test("reclaim fails when room re-filled while disconnected", async () => {
// First-come-first-served: if a stranger took the freed spot before the
// original owner reconnected, the owner gets "Room is full" — the slot
// still exists in members[] but the cap is exhausted.
const ws1 = track(await connect());
sendMsg(ws1, { type: "create", clientId: "host", maxClients: 2 });
const { room } = await waitForType(ws1, "created");

expect(msg.message).toBe("Room is full");
expect(rooms.get(room)?.active).toBe(1);
expect(rooms.get(room)?.members).toHaveLength(2);
const ws2 = track(await connect());
sendMsg(ws2, { type: "join", clientId: "guest", room });
await waitForType(ws2, "joined");

const peerLeftPromise = waitForType(ws1, "peer_left");
ws2.close();
await peerLeftPromise;

// Stranger fills the freed spot.
const ws3 = track(await connect());
sendMsg(ws3, { type: "join", clientId: "stranger", room });
await waitForType(ws3, "joined");

// Original guest tries to come back — no room.
const ws4 = track(await connect());
sendMsg(ws4, { type: "join", clientId: "guest", room });
const err = await waitForType(ws4, "error");

expect(err.message).toBe("Room is full");
expect(rooms.get(room)?.active).toBe(2);
});

test("attacker without the clientId cannot evict an existing peer", async () => {
Expand Down
13 changes: 12 additions & 1 deletion server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -362,6 +362,13 @@ function handleJoin(ws: ServerWebSocket<ClientData>, msg: { clientId: string; ro
existing.ws.data.index = undefined;
existing.ws.close(4000, "replaced");
} else {
// Reclaim of a disconnected slot. Disconnects free the cap, so by the
// time the original owner returns the room may have re-filled with new
// joiners. First-come-first-served — no special priority for prior
// owners.
if (room.active >= room.maxClients) {
return send(ws, { type: "error", message: "Room is full" });
}
room.active++;
}

Expand All @@ -377,7 +384,11 @@ function handleJoin(ws: ServerWebSocket<ClientData>, msg: { clientId: string; ro
return;
}

if (room.members.length >= room.maxClients) {
// Cap counts live clients, not allocated slots. A disconnected slot
// (battery dead, network drop, tab closed) frees the spot. New joiners
// always push to the end, so indices grow monotonically and may exceed
// maxClients in long-lived rooms with churn.
if (room.active >= room.maxClients) {
return send(ws, { type: "error", message: "Room is full" });
}

Expand Down