A live game poll needs two different moderation actions. Mute should stop a participant from publishing audio while preserving their room membership, screen-share view, and ability to vote. Kick should revoke room membership, close every media path, and remove the participant from the presence roster. The important implementation detail is not the button. It is making presence derive from server-owned session state, so a disconnected or expelled player cannot remain visible as active.
Short answer: default to mute for disruption and reserve kick for a participant who must lose access. Treat neither action as a client-side UI flag. The server must authorize publishes, votes, and reconnects against one monotonic membership record. That choice protects the live poll from the worst failure: showing a player as present after the system has stopped accepting their vote, or accepting a vote after the host believes the player is gone.
Should video room moderation mean muting or kicking participants?
WebRTC defines browser media primitives such as RTCPeerConnection, senders, receivers, and transceivers. It does not define your room membership protocol. A Node.js service still has to decide who belongs in the room, who may publish, and what a reconnect means. That boundary matters during screen share because one participant can have several active paths: microphone, incoming shared video, data messages, and the poll request itself.
Mute changes one capability. Membership stays joined; the player remains in the denominator for presence and can still receive the shared screen. Kick changes the membership epoch and closes the session. Any old socket, peer connection, or delayed vote tied to the previous epoch must then fail authorization.
I optimize this for revenue per engineering hour. A single explicit state machine is less glamorous than distributing moderation flags across socket handlers, but it is easier for one person to ship and inspect weekly. Presence is product behavior here, not plumbing.
| Action | Membership | Audio publish | Screen share receive | Poll vote | Reconnect |
|---|---|---|---|---|---|
| Mute | retained | denied | allowed | allowed | same epoch |
| Kick | revoked | denied | denied | denied | new admission required |
The table is the contract. If a UI label or transport callback disagrees with it, the contract wins.
No ambiguity.
Build the smallest server-owned policy
This TypeScript module keeps moderation separate from transport code. It uses an epoch to invalidate work created before a kick, and a sequence number so clients can reject stale presence snapshots. The sample is intentionally in memory; the policy is the useful part.
type MemberState = { status: "joined" | "kicked"; muted: boolean; epoch: number; }; type Capability = "publish-audio" | "receive-screen" | "vote"; type Presence = { sequence: number; members: Array<{ id: string; muted: boolean; epoch: number }>; }; export class RoomPolicy { private sequence = 0; private readonly members = new Map<string, MemberState>(); join(id: string): MemberState { const previous = this.members.get(id); const next: MemberState = { status: "joined", muted: false, epoch: (previous?.epoch ?? 0) + 1, }; this.members.set(id, next); this.sequence += 1; return { ...next }; } mute(id: string): void { const member = this.requireJoined(id); member.muted = true; this.sequence += 1; } kick(id: string): void { const member = this.requireJoined(id); member.status = "kicked"; member.muted = true; member.epoch += 1; this.sequence += 1; } authorize(id: string, epoch: number, capability: Capability): boolean { const member = this.members.get(id); if (!member || member.status !== "joined" || member.epoch !== epoch) { return false; } return capability !== "publish-audio" || !member.muted; } presence(): Presence { const members = [...this.members.entries()] .filter(([, member]) => member.status === "joined") .map(([id, member]) => ({ id, muted: member.muted, epoch: member.epoch })) .sort((a, b) => a.id.localeCompare(b.id)); return { sequence: this.sequence, members }; } private requireJoined(id: string): MemberState { const member = this.members.get(id); if (!member || member.status !== "joined") { throw new Error("participant is not joined"); } return member; } } The media layer should call authorize before accepting an audio publish. The poll handler should call it before recording a vote. A presence broadcaster should emit the snapshot after each accepted transition, while clients keep only snapshots with a higher sequence number. These checks belong at the server boundary; hiding a microphone icon cannot revoke authority.
There is a deliberate asymmetry. Unmuting can be another moderated transition, but a kicked participant does not recover the old epoch. Rejoining is a fresh admission decision. That small rule prevents a delayed reconnect from quietly restoring a session the host already terminated.
One epoch. One authority.
Exercise the failure paths before wiring WebRTC
I would test policy first and transport second. The state transitions contain the expensive mistakes, while socket and peer-connection adapters should stay thin enough to outsource or replace. This test covers the distinction that tends to disappear when both controls are implemented as booleans.
import assert from "node:assert/strict"; import { RoomPolicy } from "./room-policy.js"; const room = new RoomPolicy(); const first = room.join("player-17"); assert.equal(room.authorize("player-17", first.epoch, "publish-audio"), true); assert.equal(room.authorize("player-17", first.epoch, "vote"), true); room.mute("player-17"); assert.equal(room.authorize("player-17", first.epoch, "publish-audio"), false); assert.equal(room.authorize("player-17", first.epoch, "receive-screen"), true); assert.equal(room.authorize("player-17", first.epoch, "vote"), true); assert.deepEqual(room.presence().members.map((member) => member.id), ["player-17"]); room.kick("player-17"); assert.equal(room.authorize("player-17", first.epoch, "vote"), false); assert.equal(room.authorize("player-17", first.epoch, "receive-screen"), false); assert.equal(room.presence().members.length, 0); const second = room.join("player-17"); assert.notEqual(second.epoch, first.epoch); assert.equal(room.authorize("player-17", first.epoch, "vote"), false); assert.equal(room.authorize("player-17", second.epoch, "vote"), true); Run it against compiled JavaScript with Node's built-in test dependencies, or place the assertions inside the TypeScript runner already used by the application. More important, repeat the same cases through the real signaling adapter: mute during an active audio publish, kick during screen-share playback, submit a delayed vote, reconnect with the old epoch, and deliver two presence snapshots out of order.
One trap deserves special attention. Closing a socket is not the same as committing a kick. Networks disappear. Imagine the host kicks player-17 just as that player's connection drops: the disconnect callback, the moderation command, and an automatic reconnect can arrive at three different nodes in three different orders. If connection loss deletes membership, the reconnect can look like a clean join; if the kick only closes the current socket, the new socket can survive. Committing the higher epoch before cleanup gives all three paths the same answer. A temporary disconnect should follow an explicit grace policy and must not invent a moderation decision; a committed kick, by contrast, must remain authoritative when the old connection returns. Keep transport liveness and membership status as separate fields.
Order is the bug.
Wire moderation to media without trusting the browser
On mute, reject or stop the participant's audio publication at the server-controlled media boundary, then publish the new presence sequence. The browser may also stop its local audio track for immediate feedback, but that is cooperative behavior. It cannot be the enforcement mechanism. Keep the incoming screen share and poll channel authorized.
On kick, commit the revoked membership first. Then close associated signaling and media resources and broadcast presence. This order means a vote or publish racing with cleanup sees revoked authority even if a connection has not finished closing. Idempotent moderation commands make retries boring: muting an already-muted member changes nothing, and retrying a kick returns the existing revoked result rather than creating another identity transition.
Record compact audit events: room ID, participant ID, action, prior epoch, resulting epoch, moderator ID, command ID, and server time. Avoid putting poll answers or media content in that event. For operations, count rejected stale-epoch actions, presence-sequence gaps, moderation retries, and the time between revocation commit and resource closure. Those signals reveal correctness problems without turning observability into surveillance.
This is also where the weekly shipping habit helps. Start with three commands: join, mute, kick. Do not add temporary bans, role hierarchies, or timed sanctions until the product requires them. Each extra state multiplies reconnect and ordering cases.
Ship the smaller state machine.
What I would change at scale
The in-memory map is one-process code. With multiple signaling nodes, move the membership record and monotonic transition into a shared transactional store or a single-writer room actor. Broadcast an immutable event containing the resulting epoch and sequence. Consumers may receive it more than once, so applying it must be idempotent.
I would also split presence into two views: authoritative membership for authorization, and a derived display view that may include connection quality or a short reconnect grace period. Never use the softer display view to accept a vote. A player can look temporarily reconnecting while the authorization record remains exact.
The trade-off is latency versus certainty. Waiting for the authoritative transition adds a round trip before the UI confirms moderation, but optimistic confirmation can tell a host that a kick succeeded while another node still accepts the participant. For a live poll, I choose certainty. A disabled button and a brief pending state cost less than a disputed result.
Mute and kick therefore should not be competing versions of the same control. Mute preserves participation while narrowing one capability. Kick establishes a new authorization boundary. Model those meanings once, enforce them on every server entry point, and let the WebRTC layer carry media rather than decide membership.
Further reading
- W3C, WebRTC 1.0: Real-Time Communication Between Browsers: https://www.w3.org/TR/webrtc/

