| Choice | Best fit | Trust boundary | Operational cost |
|---|---|---|---|
| Server-side search | Public or mixed-trust rooms | Server returns only visible participants | Index, rate limits, and observability |
| Client-side filtering | Small, closed rooms | Client already receives the full roster | Minimal, until the roster grows |
| Peer-to-peer lookup | Rare, identity-safe sessions | Every peer can learn the shared directory | Signaling and membership complexity |
Decision rule: use server-side participant search for a multiplayer quiz game when players don't already need the full roster. Keep the search token scoped to one room and one action. Client-side filtering is the simpler runner-up for a small, trusted session in which revealing every display name is already acceptable.
This is mostly a trust decision, not a fuzzy-search decision. A Node.js service can lowercase names and rank prefixes in a few lines; the hard part is preventing a participant from turning a search box into a directory export. For a property-management session, that can mean exposing residents, staff, or contractors who aren't in the current live poll.
Ship the boundary first.
How should participant search work in a multiplayer quiz game?
Treat participant search as a projection of room membership. The browser submits a query plus a narrowly scoped capability. The server verifies the capability, resolves the current room membership, filters only that allowed set, and returns a small result containing an opaque participant ID and a display label. It should not search a global people table and then try to remove forbidden rows afterward.
That order matters. Authorization before lookup keeps the candidate set small and makes the privacy rule readable in code. It also avoids a subtle leak: even when a response hides names, different result counts or timing can reveal that a person exists outside the room. A fixed result cap and a minimum query length reduce bulk enumeration. They are controls, not proof that the design is private.
A practical flow looks like this:
- The session service issues a short-lived token bound to
roomId,participantId, and theparticipant:searchaction. - The client waits briefly while the user types, then sends the query and token to the application service.
- The service validates scope before it touches the room roster.
- Search runs against the authorized roster and returns at most the configured limit.
- The service records outcome, latency, room size band, and result count without logging the raw query or token.
Don't put an administrator's general session credential in browser storage just to power autocomplete. The UI needs one small permission. Give it one.
For the live poll itself, realtime delivery and participant discovery are separate jobs. WebRTC defines peer connections and data channels, so a game may use a data channel for time-sensitive quiz events. That transport choice does not decide who is allowed to discover whom. Membership authorization still belongs at the application boundary, before results are returned.
Token scope is the real search index
The token should express the smallest useful authority. In this case that means one room, one participant identity, one search action, and a brief expiry. A five-minute expiry can be a reasonable starting policy for a live session, but it is a design value to test against reconnect behavior, not a universal standard. I'm not sure there is one expiry that fits both a 10-minute tenant poll and a long training session; refresh frequency, expected session length, and the cost of reauthentication should settle it.
Model the claims explicitly so code review can see the boundary:
type SearchClaims = { roomId: string; participantId: string; actions: readonly string[]; expiresAtMs: number; }; type Participant = { id: string; roomId: string; displayName: string; }; function searchVisibleParticipants( claims: SearchClaims, roster: readonly Participant[], query: string, nowMs: number, limit = 8, ): Array<Pick<Participant, "id" | "displayName">> { if (nowMs >= claims.expiresAtMs) throw new Error("expired token"); if (!claims.actions.includes("participant:search")) { throw new Error("action not allowed"); } const needle = query.trim().toLocaleLowerCase(); if (needle.length < 2) return []; return roster .filter((person) => person.roomId === claims.roomId) .filter((person) => person.displayName.toLocaleLowerCase().includes(needle)) .slice(0, limit) .map(({ id, displayName }) => ({ id, displayName })); } This example intentionally accepts a roster that has already come from the membership system. In production, preserve that ownership line: the search component shouldn't invent membership, and the realtime transport shouldn't become the source of truth merely because it sees connected peers. A disconnected participant may still be eligible for a poll, while an open connection may belong to a spectator who cannot vote. Connection state and authorization answer different questions.
The client is untrusted even if the session is invite-only. It can alter a room ID, replay an old request, remove a result limit, or issue every two-character prefix. Server-side checks must ignore client claims about role and membership. Rate limits should attach to the verified participant and room, rather than only an IP address that a building's residents may share.
Client trust changes the implementation
Client-side filtering is attractive because it's tiny. Send the roster once, keep it in memory, and filter locally on each keystroke. For a host-only control panel or a closed room where every participant can already see everybody else, that may be the right trade. It removes a network round trip and avoids operating a search index. A weekly shipping cadence rewards that kind of deletion.
The catch is irreversible disclosure. Once the full roster reaches the browser, hiding rows in the interface doesn't take the data back. Developer tools, cached application state, or modified client code can still inspect it. Client filtering is therefore not suitable when search results depend on role, building, team, moderation status, or any other server-owned rule. Use server-side search in those cases.
Peer-to-peer lookup has a narrower fit. A WebRTC data channel can carry arbitrary application data between peers, but a peer directory still needs membership and signaling rules. Choose it only when peers are meant to learn the relevant identities and the product already accepts that coordination cost. For an ordinary quiz or property-management poll, adding distributed discovery is hard to justify against revenue per engineering hour. The feature is undifferentiated infrastructure; keep it boring.
Privacy also changes the display model. Search may return a session alias instead of a legal name. Duplicate labels need a safe disambiguator chosen by the application, not an email address quietly appended by the client. Empty-query behavior should return nothing unless the product explicitly promises a visible roster. These are product rules with security consequences, so write them down beside the token policy.
Test the boundary, then operate it
The most valuable tests attempt cross-room access. Create two rooms with overlapping display names, issue a token for the first, and assert that the second room never affects results. Then cover expiry at the exact boundary, a missing action, Unicode case behavior, leading whitespace, duplicate names, an empty query, and a requested limit larger than the server maximum. Property-based tests are useful for the core invariant: every returned participant must belong to the token's room.
Deployment deserves the same focus. Roll out server search behind a room-level switch, keep the old client filter only for rooms whose visibility policy permits it, and compare result latency and zero-result rates. Don't log names or query text to make a dashboard convenient. Structured fields such as operation, authorized outcome, result-count band, and duration are enough to find slow paths without creating a second participant directory in logs.
Watch four signals: rejected scope checks, throttled requests, search latency by roster-size band, and empty-result rate. A sudden rise in rejected scope checks can point to stale clients or probing, but the metric alone can't distinguish them. Correlate it with deployment version and token age before deciding. This is also where the longer paragraph earns its keep: operational data should answer a specific question, while raw queries, bearer tokens, and complete result payloads create retention and access problems that have little diagnostic value.
Server-side search costs more to own. It adds token issuance, an authorized roster query, throttling, metrics, and another behavior to test during reconnects. Stick with client-side filtering when the full roster is intentionally public to the room, room size is bounded, and role-based visibility is absent. Revisit that choice before adding cross-building sessions or searchable historical participants; the trust boundary has changed even if the search box looks identical.
A good decision note can stay short: public roster and small room, filter on the client; restricted roster or mixed trust, search on the server; peer discovery only when identity sharing is part of the session model. That rule is easier to maintain than a product comparison, and it keeps a live poll from quietly becoming a people-search endpoint.