Skip to main content

Voice chat

CrowdyJS sends proximity voice the same way it sends webcam video: opaque packets over the GraphQL UDP proxy (or the binary relay), fanned out by chunk + distance. There is no WebRTC session and the platform never decodes the audio. Your game captures, encodes, and plays.

Permission​

sendAudioPacket needs use_voice_chat on the sender's access tier and on the grid under the target chunk. A new app's default tier already carries this key. A send without it is refused with UNAUTHORIZED (error code 7) on the genericError / errors lane.

From replication server v0.43.0, a listener needs use_voice_chat too, and each player's voice settings, contacts and blocks decide who hears whom; a player under 18 has voice off until a parent consents. game.safety.commSettings(appId) says what applies to the player now, and game.safety.setCommSettings lowers it (CrowdyJS 18.24.0). See Voice, video, contacts and blocks.

If the app has replay logging on, voice is recorded with the player's other inputs unless the app leaves it out (replayLoggingRecordsVoice: false). While it records voice, your game shows players a notice and an indicator (the safety log).

Sending: udp.sendAudioPacket​

The recommended browser payload (used by The Construct and Blocks with Friends):

  • G.711 µ-law
  • 8 kHz mono
  • ~60 ms frames (480 bytes — well inside the spatial payload budget)
  • wrapping sequenceNumber (uint8) so receivers drop late or duplicate frames
await game.udp.sendAudioPacket({
appId,
chunk: session.self.chunk!,
uuid: session.self.uuid,
audioData: base64MuLawFrame, // 480 bytes µ-law
distance: 1,
sequenceNumber: (seq = (seq + 1) & 0xff),
});

Your own packets come back to you only when your actor is near the target chunk, so an echo is not an acknowledgment (and the receive handler below skips your own uuid). Prefer the fire-and-forget send; a refusal arrives as a correlated GenericErrorResponse. Enable realtime.binaryTransport: true when gameClientBootstrap.binaryRelayEnabled is true so voice (and video) does not ride one GraphQL mutation per frame.

Receiving: handlers.audio​

game.udp.subscribe({
audio: (n) => {
if (n.uuid === session.self.uuid) return;
playMuLaw(n.uuid, decodeBase64(n.audioData), n.sequenceNumber);
},
actorLeft: (n) => forgetSpeaker(n.uuid),
}, appId);

Drop duplicate or stale sequenceNumbers (an 8-bit wrap; a long gap is a new utterance). Release per-speaker Web Audio nodes on actorLeft.

Voice payload helpers​

Audio payloads are yours: the platform never reads them, and a game that already has a format keeps it. CrowdyJS 18.7.0 adds an optional convention you can adopt instead of writing your own, the same in CrowdyCPP (crowdy::media), with shared test vectors. It is a 10-byte header inside the payload, little-endian, then the codec frame:

BytesFieldMeaning
1version1
1codecVoiceCodec.RAW (0, app-defined), OPUS (1, 48 kHz mono) or MULAW (2, G.711 µ-law 8 kHz)
2seqPacket number, wraps at 65,536
4timestampSamples since the stream began, in the codec's clock (48,000 or 8,000 a second), wraps
1frameMsThe frame's duration
1flagsVoiceFlag.SPURT_START (1): first packet after silence; SPURT_END (2): last before silence
import { VoicePacketizer, VoiceJitterBuffer, VoiceCodec } from '@crowdedkingdoms/crowdyjs';

const packetizer = new VoicePacketizer({ codec: VoiceCodec.MULAW, frameMs: 60 });
// For each 60 ms frame you capture and encode:
const packet = packetizer.packetize(muLawFrame, { last: releasedPushToTalk });
await game.udp.sendAudioPacket({ appId, chunk, uuid, audioData: toBase64(packet), distance: 1, sequenceNumber: 0 });

// Receiving: one buffer for every speaker, keyed by whatever names a speaker.
const jitter = new VoiceJitterBuffer({ targetDelayMs: 60 });
game.udp.subscribe({ audio: (n) => jitter.push(n.uuid, fromBase64(n.audioData)) }, appId);
setInterval(() => {
for (const slot of jitter.poll()) {
if (slot.gap) concealFrame(slot.key, slot.frameMs); // a frame never came
else play(slot.key, slot.frame!);
}
}, 20);
  • decodeVoicePacket(bytes) returns null for a packet shorter than 10 bytes or of an unknown version; it never throws on network input. push answers 'buffered', 'late' (after its playout time), 'duplicate' or 'malformed'.
  • The buffer reorders each speaker's packets across the seq wrap, starts playing a talk spurt targetDelayMs after its first packet, reports a missing frame as a gap so you can conceal it, holds at most 64 frames per speaker, and starts over on a new spurt or after 200 ms of silence. A new spurt drops what is left of the previous one.
  • Opus in the browser: CrowdyJS ships no codec. WebCodecs' AudioEncoder / AudioDecoder encode and decode Opus (codec: 'opus', 48,000 Hz, one channel; 20 ms frames are typical) where the browser supports it; feed their frames to the packetizer with VoiceCodec.OPUS. Keep µ-law where it does not.
  • Spatial effects (panning, distance attenuation) stay in your game.

References​

  • The Construct starter: src/platform/media/VoiceService.ts
  • Blocks with Friends: src/audio/VoiceChat.ts
  • Unreal: Voice chat
  • Webcam video — the same spatial fan-out, for JPEG/WebP frames