Rooms
Proximity voice
Give rooms voice where each listener hears the closest speakers it can see.
Rooms can have voice chat in which each listener hears the speakers near
it. The room decides who hears whom; @irt/client/voice joins the voice
server and plays what the room allows.
How it works
- Voice is audio only and runs on a voice server (LiveKit) in each region, outside your VMs. It runs no tenant code.
- A project turns voice on with its
voicesetting. Each connection then gets a voice token from the router afterwelcome, in avoicemessage (type 7) with the voice server’s URL. The voice identity is the player ID, stable across reconnects. - Who hears whom comes from the room. With
voice: {}indefineRoom, a listener hears the players whose entities it can see under the room’s interest, ranked by the interest limit’spriority, capped atmaxSpeakers. An entity belongs to the player in itsownerstring field, unless you passowner.audible(room, listener)replaces the rule, for teams, radio channels or a whole room on one call. - Each tick the room works out every connection’s speakers (who it
hears) and listeners (who hears it) and sends them, only when they
change and at most 4 times a second, in a
peersmessage (type 9). - The client subscribes only to its speakers, and sets its own microphone track’s permissions to its listeners, so a modified client can’t overhear anyone and can only expose its own voice. A subscribe that arrives before a speaker’s permission is retried when the permission comes.
- By default each speaker plays through its own
<audio>element, with volume falling linearly to silence atrange.spatial: truepans speakers in 3D with Web Audio, played through one<audio>element, because Chrome’s echo cancellation ignores audio played straight from Web Audio.
Room
// room.ts
import { defineRoom, defineSchema, entity, grid, type RoomEntity } from "@irt/room";
export const Avatar = entity("avatar", { owner: "string", x: "f32", y: "f32" });
export const schema = defineSchema({ entities: [Avatar] });
type A = RoomEntity<typeof Avatar>;
export default defineRoom({
schema,
interest: {
...grid({ cellSize: 16, radius: 1, dims: "xy", around: (_room, c) => c.data.avatar }),
limit: {
max: 64,
priority: (_room, c, e) => -Math.hypot((e as A).x - (c.data.avatar as A).x, (e as A).y - (c.data.avatar as A).y),
},
},
voice: { maxSpeakers: 6 },
onJoin(room, conn) {
conn.data.avatar = [...room.entities.all(Avatar)].find((e) => e.owner === conn.player) ?? room.entities.create(Avatar, { owner: conn.player, x: 0, y: 0 });
},
onPlayerLeave(room, player) {
for (const e of room.entities.all(Avatar)) if (e.owner === player) room.entities.delete(e);
},
});
Voice options on defineRoom({ voice }):
| Option | Default | Meaning |
|---|---|---|
maxSpeakers |
8, or 256 with audible |
Speakers each listener hears, at most 256. The project’s voice.maxSpeakers setting is a ceiling: a room gets the smaller of the two |
owner(entity) |
its owner string field |
The player an entity speaks for |
audible(room, listener) |
speakers it can see | Player IDs the listener hears, replacing the default rule |
Whole room on one call
Party games and calls want everyone to hear everyone, whatever they can
see. Return every connection’s player from audible; the listener’s own
ID is dropped for you:
// call-room.ts
import { defineRoom, defineSchema } from "@irt/room";
export const schema = defineSchema({});
export default defineRoom({
schema,
voice: { audible: (room) => [...room.connections.values()].map((c) => c.player) },
});
With audible the room’s own cap defaults to 256, and the project’s
voice.maxSpeakers setting, 64 by default, caps it, so a call of up to
65 players needs no setting. On the client, leave
out position so every speaker plays at full volume; see
Client.
Muting a player
room.muteVoice(player) mutes a player in this room, for good or until
{ expiresAt } in wall-clock milliseconds; room.unmuteVoice(player)
lifts it, and room.voiceMutes() lists them. The room leaves a muted
player out of every listener’s speakers, whatever audible or the
default rule says, so no client the room drives subscribes to it, and
its own client sees no listeners. Mutes are kept in room state, like
bans, so they last through hibernation and a room host restart.
calls: {
mute: (room, conn, args: { player: string }) => {
if (conn.player === room.creator) room.muteVoice(args.player);
},
},
The voice server enforces a mute too: the player can’t publish while
it lasts, and its voice tokens can’t either, so neither a modified
client nor a reconnect gets around it. A player on the voice server is
muted at once, and one who joins it with a token from before the mute
is muted as it joins; should the voice server miss either, within a
minute. When the mute ends, Voice turns the player’s microphone back
on if it was on, unless the player muted it with setMuted. The server
API’s voice/mute is a one-off mute of the player’s tracks, which its
own client can undo.
Client
// client.ts
import { connect } from "@irt/client";
import { joinVoice } from "@irt/client/voice";
import { Avatar, schema } from "./room.js";
const room = await connect({ router: "wss://eu.irt.io", project: "my-game", room: "plaza", schema });
const voice = joinVoice(room, {
position(player) {
const a = [...room.entities.all(Avatar)].find((e) => e.owner === player);
return a && { x: a.x, y: a.y };
},
range: 24,
});
document.querySelector("#talk")!.addEventListener("click", () => void voice.enableMic());
document.querySelector("#mute")!.addEventListener("click", () => void voice.setMuted(!voice.muted));
function joinVoice(room: RoomClient, opts?: VoiceOptions): Voice;
interface VoiceOptions {
position?(player: string): { x: number; y: number; z?: number } | undefined;
spatial?: boolean; // default false
range?: number; // default 20
}
class Voice {
readonly project: string; // the room connection's project
readonly muted: boolean; // this player's mute, from setMuted
readonly micOn: boolean; // enableMic called and not muted
readonly participants: ReadonlySet<string>; // other players on the voice server
readonly speaking: ReadonlySet<string>; // players speaking now, this one included
readonly mutedPlayers: ReadonlySet<string>; // other players on voice sending no audio
on(event: "participants" | "speaking" | "mutedPlayers", fn: (players: ReadonlySet<string>) => void): () => void;
on(event: "micError", fn: (error: Error) => void): () => void;
enableMic(): Promise<void>;
setMuted(muted: boolean): Promise<void>;
disconnect(): Promise<void>;
// Player controls; see below
volume(player: string): number; // 0 to 1, default 1
setVolume(player: string, volume: number, keep?: boolean): void;
readonly blocked: ReadonlySet<string>;
setBlocked(player: string, blocked: boolean): void;
devices(): Promise<{ inputs: VoiceDevice[]; outputs: VoiceDevice[] }>; // VoiceDevice: { id, label }
readonly input: string | undefined; // chosen microphone; undefined for the default
readonly output: string | undefined; // chosen speaker; undefined for the default
readonly canSetOutput: boolean; // the browser has setSinkId
setInput(deviceId: string): Promise<void>;
setOutput(deviceId: string): Promise<void>;
readonly level: number; // microphone input level, 0 to 1
readonly pushToTalk: boolean;
setPushToTalk(on: boolean): Promise<void>;
talk(down: boolean): Promise<void>;
}
positionplaces players for volume and panning, and a speaker it returns undefined for is silent. Leave it out and every speaker plays at full volume, unpanned, as on a call;spatialandrangethen do nothing.participantsis the set of other players’ IDs on the voice server, whether or not the room has this player hear them: everyone a voice chat list should show. A player in the room who hasn’t joined voice, or whose voice dropped, isn’t in it. Theparticipantsevent fires with the new set each time a player joins or leaves voice.speakingis the set of player IDs the voice server hears talking now, this player’s included, so it covers speakers this client doesn’t hear too. Thespeakingevent fires with the new set each time it changes.enableMicwhile the room mutes this player publishes nothing until the mute ends, then turns the microphone on. Called before voice has joined, it turns the microphone on when it does.micErrorfires when the microphone fails to start, as when the player refuses permission, whether fromenableMicor on joining. The microphone is then off again, somicOnis false and the nextenableMicasks again;enableMicalso rejects with the error. An error namedNotAllowedErroris a refusal.mutedPlayersis the other players on the voice server sending no audio: muted withsetMuted, or not yet pastenableMic. ThemutedPlayersevent fires with the new set;voice.mutedstays this player’s own mute. A player who isn’t on the voice server is in neither set.- The three sets empty when voice drops and fill again after it rejoins, so
keep the same listeners across reconnects; there’s nothing to rebind.
voice.livekitis replaced on each reconnect, so listeners on it would need binding again.
Player controls
These are the player’s own settings, which the project shows in its UI;
the UI kit’s <irt-voice-settings> and
<irt-voice-chat> are built on them.
setVolume(player, volume)sets how loud this player hears another, from 0 to 1, multiplied into the distance attenuation on both the<audio>and thespatialpath. It can’t go above 1: an<audio>element’s volume is capped there, and gain past it would need Web Audio, which Chrome’s echo cancellation ignores. Withkeepfalse it isn’t saved, for a slider being dragged; save the value it stops at.setBlocked(player, true)is a local mute: the client never subscribes to a blocked player, whatever the room picks, so a blocked player costs no bandwidth. Other players still hear them. To silence a player for everyone, the room callsmuteVoice.- Volumes and blocks are kept in localStorage per project by player ID, so a player blocked in one room stays blocked in the next. Where the browser blocks storage they last for the page.
devices()lists microphones and speakers. Labels are empty until the page has microphone permission, so list them again afterenableMic.setInputandsetOutputswitch devices, and a device chosen before voice joins is used when it does. Speakers can only be chosen where the browser hassetSinkId, whichcanSetOutputreports; elsewheredevices()lists none andsetOutputthrows.levelis the microphone’s input level from 0 to 1, 0 while it isn’t sending. Read it on animation frames for a meter.setPushToTalk(true)keeps the microphone muted except betweentalk(true)andtalk(false).Voicedoesn’t bind keys; the page does, and should ignore key presses in text fields:
// push-to-talk.ts
import { connect } from "@irt/client";
import { joinVoice } from "@irt/client/voice";
const room = await connect({ router: "wss://eu.irt.io", project: "my-party", room: "lobby" });
const voice = joinVoice(room);
await voice.setPushToTalk(true);
const typing = (e: KeyboardEvent) => e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement;
addEventListener("keydown", (e) => {
if (e.code === "KeyV" && !e.repeat && !typing(e)) void voice.talk(true);
});
addEventListener("keyup", (e) => {
if (e.code === "KeyV") void voice.talk(false);
});
document.querySelector("#mic")!.addEventListener("click", () => void voice.enableMic());
Indicators
// indicators.ts
import { connect } from "@irt/client";
import { joinVoice } from "@irt/client/voice";
const room = await connect({ router: "wss://eu.irt.io", project: "my-party", room: "lobby" });
const voice = joinVoice(room);
function badge(player: string): string {
if (voice.mutedPlayers.has(player)) return "muted";
return voice.speaking.has(player) ? "speaking" : "";
}
function show(): void {
const onVoice = [...room.presence].filter((p) => voice.participants.has(p.player));
console.log(onVoice.map((p) => `${p.player} ${badge(p.player)}`));
}
for (const event of ["participants", "speaking", "mutedPlayers"] as const) voice.on(event, show);
The voice follows the room connection: it leaves the voice server when
the connection drops, joins again with the new token after the
reconnect, and ends when the connection closes for good. Without the
project’s voice setting the router sends no token, and joinVoice
does nothing. room.peers ({ speakers, listeners }) and the peers
event are on every RoomClient, so a project can show who’s talking
without @irt/client/voice, which is a separate entry so projects without
voice don’t load livekit-client.
livekit-client is most of a voice game’s bundle, about 570 KB
minified. Import @irt/client/voice dynamically, once the game has
connected or when the player opts in, and the scaffold’s build-web.mjs, which bundles with esbuild’s
splitting, puts it in a chunk of its own that loads only then:
// lazy-voice.ts
import { connect } from "@irt/client";
const room = await connect({ router: "wss://eu.irt.io", project: "my-party", room: "lobby" });
const { joinVoice } = await import("@irt/client/voice");
const voice = joinVoice(room);
document.querySelector("#talk")!.addEventListener("click", () => void voice.enableMic());
A build script of your own needs splitting: true, format: "esm" and
outdir rather than outfile for this; without splitting, esbuild
inlines the dynamic import into the main bundle. Keep enableMic in a
click handler of its own, after the import has resolved, since browsers
grant the microphone only from a user gesture.
Testing
Test clients expose the room’s decision, so proximity voice is testable without a voice server:
// voice.test.ts
import { testRoom } from "@irt/testing";
import { expect, it } from "vitest";
import room, { Avatar } from "./room.js";
it("hears nearby players", async () => {
const t = testRoom(room);
const alice = await t.connect({ player: "alice" });
const bob = await t.connect({ player: "bob" });
await t.host.tick();
expect(alice.speakers).toEqual(["bob"]);
expect(bob.listeners).toEqual(["alice"]);
const far = [...t.live.entities.all(Avatar)].find((a) => a.owner === "bob")!;
far.x = 500;
await t.host.tick(10);
expect(alice.speakers).toEqual([]);
});
Turning it on
Voice is a project setting:
| Setting | Default | Meaning |
|---|---|---|
voice.enabled |
false |
Connections get a voice token |
voice.maxSpeakers |
64 |
1 to 64. A ceiling on every room’s speakers per listener: a room gets the smaller of this and its own maxSpeakers, whose default of 8 is unchanged for proximity voice. A change applies to each room from its next wake |
Declare them in irt.config.ts:
settings: { voice: { enabled: true } },
Locally, voice is off unless you run a LiveKit server and point irt dev
at it; peers messages arrive either way, so the room’s rule can be
checked without one:
docker run -d --rm --network host livekit/livekit-server:v1.9.1 --dev --bind 127.0.0.1
IRT_LIVEKIT_URL=ws://127.0.0.1:7880 npx @irt/cli dev
IRT_LIVEKIT_KEY and IRT_LIVEKIT_SECRET default to LiveKit’s dev
mode devkey and secret. The token irt dev mints has the grants the
router’s does: identity the player ID, LiveKit room {project}/{room},
publishing only the microphone, and subscribing.
Common mistakes
- Leaving out
voiceindefineRoom. Without it the room sends nopeers, and every client hears no one. - An interest that shows everyone everything. With no interest, every
connection sees every entity, so a listener hears the first
maxSpeakersplayers rather than the nearest. - A
prioritythat ranks far entities higher. Priority is “higher is more important”; use the negative of the distance. - Entities without an owner. The default rule only counts entities
with an
ownerstring field naming a player; passownerotherwise. - Calling
enableMicoutside a click. Browsers only grant the microphone from a user gesture. - Expecting voice to keep a room awake. Rooms hibernate 30 seconds after their last connection closes, whatever voice is doing.