Voice Chat
Relays opaque game-encoded audio frames by voice channel, or by proximity when World is installed; the framework never decodes — codec + playback are the game's.
Seams you implement 3
Crossplay calls these; your game supplies them. Each ships an inert or permissive default, so register yours before services.AddCrossplayVoice(); and it wins.
IVoiceChannelPolicy game SPI
Decides whether a session may JOIN a voice channel, and whether it may SPEAK on one. Nothing is composed by default: with no policy registered every authenticated session may join and speak on any channel in range, exactly as before this seam existed. Why a game wants this. A channel is addressed by a plain number. A game that assigns channels server-side — a show's stage, a squad, a raid — has no way to stop a signed-in client from asking for channel 7 and listening to somebody else's conversation, and with RequireChannelMembershipToSpeak on, self-joining is also how it earns the right to talk there. The piece cannot know which channels a game handed out, so the question belongs to the game; the piece only asks it. Narrow only, never widen. The policy is asked AFTER the framework's own gates (authenticated session, channel in range, frame non-empty and within MaxAudioBytes), so returning true can never admit something the piece already refused. It cannot route audio anywhere either — who HEARS a frame is IVoiceProximityAudience and channel membership, not this. Refusals are indistinguishable. A refused join or frame is dropped silently, exactly as an out-of-range channel already is, so probing channel numbers tells a client nothing about which ones exist. That is deliberate: a reason code here would be a map of the server's live conversations.
-
bool MayJoin(ISession session, byte channel)May session subscribe to channel?
-
bool MaySpeak(ISession session, byte channel)May session put audio on channel?
IVoiceFrameObserver game SPI
Watches the audio frames the server accepts, without changing where they go. Nothing is registered by default: with no observer composed, the relay does exactly what it did before (one null check), and a game that never implements this cannot tell the seam exists. Why a game wants this. The relay is deliberately deaf — it moves opaque bytes and never decodes them. But a server often needs to KNOW what was said while the room hears it: transcribe the speaker, measure how loud a reaction was, keep the last few seconds for a report, sample a channel for moderation. Without a tap each of those costs the game a SECOND uplink of the same audio as its own message, so every speaker sends every frame twice. What it does not do. An observer is a listener, never a gate: it cannot refuse a frame, rewrite it, or redirect it (a game that wants to decide who hears what registers its own IVoiceService, or answers IVoiceProximityAudience; a game that wants to refuse speech answers IVoiceChannelPolicy). It is called for every frame the server ACCEPTED — authenticated speaker, non-empty, within MaxAudioBytes, and allowed by the channel policy when one is composed — whether or not anyone was listening, because "what was said" and "who heard it" are different questions and only the first is answerable here.
-
void OnVoiceFrame(ISession speaker, byte channel, ushort seq, ReadOnlySpan<byte> audio)One accepted audio frame, as it is relayed.
IVoiceProximityAudience game SPI
Resolves WHO hears a proximity (Channel 0) voice frame — the listeners "near" the speaker. Why this is a seam. Channel voice is pure Core rooms and needs nothing spatial, so a card game or a party game installs Voice with no world at all. "Near" only means something to a game that has a world, so the spatial half lives OUTSIDE the piece: the Hosting composition answers this seam from World interest when World is installed, and with nothing registered the proximity route is simply unavailable (Channel-0 frames are dropped) while channel voice keeps working. Voice itself references no spatial piece — the same shape as Chat's IChatAudienceResolver.
-
bool TryCollectListeners(ISession speaker, List<ISession> into)Fills into with the sessions that should hear a proximity frame from speaker.
Services you call 1
Crossplay implements these. Resolve them from DI and call them from your own systems.
IVoiceService
Relays opaque, game-encoded audio frames. Two routes, chosen per frame by Channel: proximity (Channel 0 → the listeners near the speaker, resolved through IVoiceProximityAudience — World interest when the composition bridges it) and channel (Channel > 0 → everyone joined to that voice channel, a Core room). The piece NEVER decodes or interprets the audio — it is entirely the game's vocabulary. Frames relay unreliably (voice is a loss-tolerant real-time stream); each frame is serialized once and the bytes reused for every recipient, like the movement/emote broadcast. Spatial-optional. The channel route is pure Core rooms — it works with no World piece, so a non-spatial game (voice channels / party chat, no world) gets full channel voice. The proximity route is inherently spatial: with no IVoiceProximityAudience composed it is simply unavailable (a Channel-0 frame is dropped), since "who can hear me nearby" has no meaning without a world. The piece references no spatial piece; the Hosting composition supplies the audience.
-
void Relay(ISession session, VoiceFrameRequest request)Validates and relays a frame to the speaker's proximity observers or a channel.
-
void SetChannel(ISession session, byte channel, bool join)Joins or leaves a voice channel (to receive/stop receiving its frames).
Configuration 1
Every tunable lives in an options object — there are no magic numbers to hunt for.
VoiceOptions
Configurable voice-relay parameters (defaults here; never inlined in logic).
-
bool EchoToSelf { get; set; }Echo the speaker's own frames back to them. Off by default — you don't hear yourself; a game doing local monitoring turns it on.
-
int MaxAudioBytes { get; set; }Maximum size of an opaque audio frame, in bytes. Sized for one codec packet (~20-60 ms of Opus is well under 1 KiB); anything larger is dropped as malformed/abusive. Per-speaker flood control is the kernel's per-message-type rate limiting, configured by the game. Default 1024; raise only if the game's codec emits larger packets.
-
int MaxChannel { get; set; }Highest voice-channel number a client may use (1..MaxChannel). 0 is proximity. Default 32; size it to the number of concurrent channels the game needs (party / team / global).
-
bool RequireChannelMembershipToSpeak { get; set; }Require the speaker to have joined a channel before relaying to it. On by default (a stray frame to a channel you never joined is dropped); off lets a game push to channels freely.
Wire messages 4
The protocol this piece speaks. Ids are allocated per piece so they can never collide.
VoiceFrameRequest Client → server: an opaque encoded audio frame to relay. Audio is a game-encoded blob — the framework never decodes it (Opus, PCM, whatever the game chose). Channel 0 = proximity (relayed to whoever can see the speaker in the world); a value > 0 = a voice channel (relayed to everyone joined to that channel). Sent unreliably — voice is a loss-tolerant real-time stream, not a discrete event.
EntityVoiceFrame Server → recipients: a relayed audio frame from a speaker (unreliable). Clients decode Audio with their own codec and play it positionally (proximity) or flat (channel) — the framework doesn't care.
VoiceChannelRequest Client → server: join or leave a voice channel (to receive/stop receiving its frames).
VoiceChannelAssigned Server → client: the voice channel the SERVER put this client on (a room, a team, a party), and who speaks on it. Sent whenever that changes; Channel 0 means "taken off it". A client never asks for these channels, which is what stops it listening to a conversation it is not in — it only needs the number to send on.
Unity seams you implement 1
The client half's sockets. Bind your implementation in the client context and Crossplay's client calls it — this is where your models, animators, UI and controls plug in.
IVoiceChannelAssignments unity SPI
Channels the SERVER assigns (a lobby room, a team): which one you are on and who speaks on it. Its own interface, not new IVoiceClient members, so no implementation of that published interface has to change (rule 25). Nothing arrives unless a server-side bridge assigns channels (e.g. Crossplay:LobbyVoice).
-
event Action<VoiceChannelAssigned> ChannelAssignedThe server put this client on a channel, took it off one (Channel 0), or the speakers changed.
-
byte AssignedChannelThe channel the server has this client on, or 0.
Unity services you inject 1
Crossplay binds these in the client context. Inject and call them from your own MonoBehaviours and presenters.
IVoiceClient
The Voice puzzle piece, client side. Capture audio with your own codec and push opaque frames — proximity (channel 0) or a voice channel (> 0); receive EntityVoiceFrame events to decode + play however you like (positional for proximity, flat for channel). The framework never decodes the audio — codec and playback are entirely the game's. Remove this package and voice simply stops; nothing else breaks.
-
event Action<EntityVoiceFrame> FrameReceivedA frame arrived from a speaker you can hear (a nearby entity, or a channel you're in).
-
void Send(byte channel, byte[] audio)Sends an opaque audio frame. Channel 0 = proximity; > 0 = a voice channel. Unreliable. Each frame is stamped with a per-channel sequence number the server relays verbatim, so a receiver can order frames, drop duplicates and notice losses (see Seq). A game that plays frames in arrival order can ignore it.
-
void JoinChannel(byte channel)Joins a voice channel (to receive/send on it).
-
void LeaveChannel(byte channel)Leaves a voice channel.
UI views you can replace 1
Each ships a working uGUI panel you can drag into a scene. Substitute your own view to keep the logic and change the look — the presenter never knows.
ICrossplayVoiceView
Narrow contract the presenter drives. UI-only: render calls in, user intents out.