Introduction
Mumble
Mumble is a low-latency voice communication protocol. Free client implementations are available for Windows, macOS, Linux, Android and iOS.
Participants are represented in a tree of channels maintained by the server. Audio is normally exchanged between participants in the same channel.
In a conventional deployment, one channel tree is shared by all participants. Its structure is defined through configuration or administrative actions. Channel changes are initiated by participants, administrators or external scripts.
Mumble in games
Mumble is often used alongside multiplayer games, particularly for positional audio.
Through Mumble Link, positional and contextual data may be transferred from a game client to the local Mumble client. Audio positioning can then be derived from information such as the current map, player position and orientation.
This integration remains local to each participant. No authoritative game state is synchronized with the Mumble server through Mumble Link.
Authoritative state is often held by the game server instead. It may include:
- team membership,
- match phase,
- player role,
- alive or spectator state,
- location or region,
- permissions derived from game rules.
Voice behaviour based on this state usually requires custom channel management, permissions or scripted moves. The resulting voice structure is maintained in parallel with the game state.
Mumble Server Runtime
Mumble Server Runtime is a Mumble-compatible server for application-controlled voice sessions.
The channel structure, participant visibility and audio routing are derived from authoritative state held by another system. That authority may be a game server, a match controller, a dispatch system or a training platform.
No voice topology is fixed in a configuration file. The current voice session is computed from the current application state.
Voice rules are defined in application code. Changes to authoritative state are therefore reflected in the corresponding Mumble session without a separately maintained channel model.
Connections are accepted from unmodified Mumble clients. No plugin, custom client or protocol extension is required.
Mumble Link and Mumble Server Runtime address separate parts of game integration. Positional metadata may still be provided through Mumble Link, while server-controlled visibility, grouping and audio routing are derived through the runtime.
Application-controlled voice
A voice session consists of three elements:
- the channels visible to each connection,
- the participants visible to each connection,
- the directed audio relation between participants.
After a relevant change in authoritative state, the affected portion of the voice session is recomputed. Only the resulting difference is propagated to the affected connections.
The voice session is therefore a live projection of application state rather than a second structure maintained independently.
For a game, the rendered voice state may include:
- placement in a cave-specific voice group after entry into a cave,
- removal of team channels at the end of a round,
- modified audibility after assignment of a role,
- removal from the living-player view after transition to spectator state,
- audibility derived from distance rather than channel membership.
No manual channel selection or administrative intervention is required. A change in application state and the corresponding change in voice state form one operation.
Per-connection views
A view is the portion of a Mumble session represented to one connection. It contains the visible channel tree, the participants represented within that tree and the speakers whose audio may be received.
Views are computed independently for each connection and may differ at the same instant. Different channel trees may therefore be sent to two clients connected to the same server.
For example, only one team may be visible to a player, while both teams remain visible to a spectator. A separate structure may be visible to staff members.
Each result remains an ordinary Mumble session from the client perspective.
Visibility and audibility
Visibility is the set of channels and participants represented to a client.
Audibility is the directed relation defining which participants may receive audio from which speakers.
These concerns are represented separately. Visibility does not imply audibility, and audibility may be one-way.
The following topologies can therefore be represented:
- teams hidden from one another while sharing a pre-match lobby,
- spectators receiving audio from both teams without being audible,
- staff remaining outside participant views until addressing a group,
- proximity voice derived from distance,
- private communication visible only to selected participants.
A single shared channel tree cannot represent these cases directly. Channel membership is the only available grouping mechanism, so visibility and audibility remain coupled. Approximation through permissions and scripted channel moves requires additional special cases for each role.
Responsibility boundary
Application state remains outside the runtime. Domain concepts such as players, matches, teams, roles and locations are not inspected.
Runtime ownership is limited to Mumble-specific state:
- network connections,
- protocol sessions,
- encryption state,
- client views,
- audio routing.
Two directions cross that boundary.
Downward, state. A render function is supplied by the application and evaluated after a relevant change in authoritative state. Its result consists of the desired views and directed audio relations. Mumble protocol messages are derived exclusively from that result.
Upward, events. Client-initiated actions are reported to the application: connection and disconnection, a request to enter a visible channel, a request to self-mute or self-deafen, and the invocation of a menu entry defined by the application itself.
Such events are requests rather than facts. No voice state is modified by their delivery. Authoritative state is updated by the application when a request is accepted, and the consequence becomes visible through the next render. A refusal consists of rendering nothing new, optionally accompanied by a message returned to the connection.
At a high level:
render
authoritative state ───────▶ voice state ───────▶ Mumble sessions
▲ │
└────────────────────────────────────────────────┘
events
Reading this book
The documentation is divided into the following parts:
- Boundaries states what the runtime does not do, and which values are fixed rather than configurable.
- The model defines views, scopes, overlays, audio relations, deltas and propagation cost. No types or code appear there.
- Building an application covers the demonstration application, the render function, connection routing and configuration. Types and code appear there.
- Mumble compatibility covers what an unmodified client gets, what it is refused, and what backs the word compatible.
Generated API documentation is published separately. See API documentation.
Boundaries
What the runtime does not do, and what is fixed rather than configurable. Stated early, because most of it follows from one decision: a client asks, and only the application decides.
Nothing is driven from the client
The voice state comes from the render and from nowhere else. Control messages that would change it directly are refused, with a denial carrying a reason:
- creating, editing or removing a channel,
- removing a participant, and moderation generally,
- editing access control lists,
- registering whisper or shout targets,
- requesting avatars, comments or other blobs,
- renegotiating the connection’s encryption.
A client requesting entry to a channel is reporting an intention. The move happens when the application renders it, or never.
Permissions are not a stored model either. The flag marking a channel as enterable is a display hint, and an actual join is validated again when it arrives.
Messages are rate-limited to one per second with a burst of five, the same allowance as the reference server. Anything beyond that is refused with a reason rather than queued.
No domain knowledge
Matches, teams, roles, positions and the rules deriving from them stay in the application. None of it is inspected, stored or interpreted by the runtime.
Nothing outlives the process on the runtime’s side. Channels and participants exist as the current render produces them, so persistence is the application’s concern alone.
Positional audio is not provided. Position may still reach the client through Mumble Link, which is a separate and local mechanism.
Audio
Opus only. The payload is never decoded, only forwarded, so codec conversion and server-side mixing are out of scope.
Positional data present in an incoming packet is stripped before forwarding. Passing on coordinates the application never authorised would leak position.
Rate limits on voice are per connection and fixed: a sustained rate with a short burst allowance, and a maximum packet size. A connection exceeding them has packets dropped rather than being disconnected.
Fixed in the model
None of these is settable from application code.
| limit | value |
|---|---|
| scope depth | 4 segments |
| scopes observed per connection | 4 |
| delta history retained | 256 versions |
| context actions offered per connection | 64 |
| output queue per connection | 1024 messages |
| minimum interval between publications | 50 ms |
Each bound has a cost behind it rather than a preference. Exceeding one refuses the render, except for the queue, whose overflow closes the connection rather than letting its view drift.
Configuration
There is no configuration file and no environment variable. Everything is Rust code: the bind address, the advertised user ceiling, the welcome text, the message length limit, whether HTML is allowed in messages, and the advertised version.
Two of those are advertised rather than enforced. The bandwidth ceiling is sent to clients and not applied server-side. The recording-allowed flag is advisory.
Transport
TLS 1.2 only, because a widely deployed client build crashes on a TLS 1.3 session.
A client certificate is requested but not required, and when present it proves identity rather than authority. What that identity is worth is decided by the application when a connection is routed.
Self-signed certificate generation exists for local development. A deployment supplies a persistent certificate instead, otherwise every restart looks like a new server to clients, which key their local preferences on it.
The model
What the runtime is, stated once and precisely. Views, scopes, overlays, the audio relation, deltas, the one place visibility and audibility touch, and what a turn costs.
No types and no code appear in this chapter. It defines the vocabulary the rest of the book uses, and it can be read without any intention of writing an application.
Reading it in order is worth the trouble. Each definition is built from the ones before it, starting at Vocabulary.
Readers who would rather see the thing run first should start at Running the arena and come back.
Vocabulary
Terms used throughout this book, ordered so that each definition draws only on terms already defined.
Shard
A unit of rendering, ownership and scheduling. One shard holds one region of application state, produces one shared view, and keeps one history of changes. A connection is attached to exactly one shard at a time.
Connection
One client socket, with its own encryption state and output queue. Identified by
a ConnectionId, which is the identifier application code works in.
Occupant
Whoever fills a rendered participant slot. Either a connection, or a synthetic participant with no socket: a non-player character, a bot, someone present in the application but not in Mumble. The occupant is the participant’s identity, so no separate key is needed.
Session identifier, channel identifier
The numbers carried on the wire. Allocated once and never reused, because local client preferences are keyed on them: nickname overrides, per-participant volume, channel filter mode. Reuse would apply one participant’s settings to another.
Channel key
A stable identity for a channel, chosen by the application and held across renders. Distinct from the channel’s name, which is an ordinary field. A channel whose identity came from its name would be destroyed and recreated every time the name changed.
Shared view
Every channel and participant a shard renders, each fact held once regardless of how many connections end up seeing it. Holding facts once instead of copies is what keeps the cost linear.
Scope
A position in a tree, carried by every rendered element and observed by every connection. The mechanism that makes one shared view yield different results per connection without duplicating it. See The shared view and scopes.
Overlay
The elements visible to exactly one connection. Reserved for individual exceptions, where a scope describes a group. See Overlays.
View
What one connection holds: the shared view restricted to what that connection observes, composed with that connection’s overlay. Complete only on the client. Kept on the server as the shared view, one overlay per connection, and each connection’s position in the history.
Audio relation
The directed relation over connections deciding who may receive whose voice. Declared outright, independently of the channel tree and of scopes. See Audio as a relation.
Render
One evaluation of the application logic, producing the shared view, the overlays and the audio relation. Evaluated once per shard after a change in application state, never once per connection.
Delta
The operations separating two consecutive shared views: channels created, updated or removed, participants added, moved, updated or removed. See Deltas and per-connection composition.
Journal
The bounded history of deltas kept by a shard. Each connection holds a position in it. A connection left behind the oldest retained entry is closed rather than served a view that has drifted.
Application logic
The code supplying the three operations a shard calls: the render, the set of
scopes a given connection observes, and the reaction to a client event. Written
as an implementation of the ShardLogic trait, and the only place the domain
rules live.
Application state
Matches, teams, roles, positions, permissions derived from game rules. Owned by the application and never inspected by the runtime.
The shared view and scopes
A scope is a position in a tree, at most four segments deep. Every rendered element occupies one. Every connection observes a set of at most four.
Segments are opaque numbers. Their meaning belongs to the application: a match identifier, a team identifier, a region.
Derived, never declared
The builder has no scope parameter. A scope is derived from the element it hangs under, plus one instruction:
Narrow::Samekeeps the parent’s scope.Narrow::Into(segment)descends one segment below it.
A channel’s scope is derived from its parent’s, a participant’s from that of the channel it is rendered in, the shard’s root from the root scope.
Narrowing is the only available direction. There is no widening instruction, and beyond four segments the operation is refused rather than saturated. Saturating would hand a child its parent’s visibility, which is a disclosure dressed as a rounding error. The whole render is refused instead, and the previous view stays in place.
Visibility
A connection sees an element when one of its observed scopes is comparable to the element’s, meaning one is a prefix of the other.
Both directions count. An observer at /g7/t2 sees its ancestors, /g7 and the
root, along with everything below /g7/t2. An observer at /g7 sees /g7/t2
and /g7/t3 alike. Moving up the tree is how an observer comes to see more.
scope tree observer at that scope sees
/ staff every scope below
|
+- /g7 spectator /, /g7, /g7/t2, /g7/t3
|
+- /g7/t2 red player /, /g7, /g7/t2
|
+- /g7/t3 blue player /, /g7, /g7/t3
The red player and the blue player do not see each other. Comparability is
reflexive and symmetric, and deliberately not transitive: /g7/t2 and /g7/t3
are both comparable to /g7 and not to each other. That is what keeps two teams
apart while a spectator above them sees both, with nothing added to the model.
The closure property
A channel’s scope extends its parent’s and a participant’s extends its channel’s. One property follows without a runtime check: seeing an element implies seeing what that element refers to.
Writing c for a channel’s scope and u for a participant’s, c is a prefix of
u. An observer seeing the participant does so through some scope s comparable
to u, which leaves two cases. If s is a prefix of u, then s and c are
two prefixes of one path and therefore comparable. If u is a prefix of s,
then c is a prefix of s. The channel is visible either way.
A participant is therefore never visible in an invisible channel, and a channel never visible without its parent. There is no consistency check to run and no failure to handle, because the incoherent case cannot be expressed.
A scope is not a channel
The two trees are independent. One channel may hold participants of several scopes, each visible to a different set of observers.
The rule tying them is extension, not equality, so a channel left at the root scope may hold participants narrowed into their respective teams. Each player then sees the channel and, inside it, only their own team.
Bounds
Four segments of depth. Four observed scopes per connection. Both are refused past the limit rather than clamped.
Neither bound is advisory. A scope is a copied value consulted once per connection per turn, so an unbounded path would require an allocation on the composition path, and the quadratic cost this model exists to avoid returns as soon as the observation bound is lifted.
Choosing a scope
A scope describes a group. An individual exception belongs in an overlay instead. A scope with one observer is an overlay in disguise.
A role is a group by nature even while it holds a single member: player, host, spectator, staff. A vanished administrator is not a role but one person’s exception.
Overlays
An overlay is the set of elements visible to exactly one connection: channels and participants held apart from the shared view, and recomputed from scratch on every render.
Scopes describe groups. Overlays describe individual exceptions: a vanished administrator, a private channel one person holds, a participant shown to a single observer in a place nobody else sees.
Not an override layer
An element is shared or private, never both. A channel or a participant present in the shared view and in any overlay refuses the render.
Composition is therefore a disjoint union rather than a layering. No field of a shared element can be replaced through an overlay. An overlay carries only elements the shared view does not.
The exclusion is worth a refusal because of what the alternative permits. With both allowed, the shared view could say the administrator stands in the staff channel while an overlay says the same administrator stands in a player’s channel. The next shared delta would then move the administrator without the overlay reasserting itself, and the client would drift with nothing left to detect it. Silent divergence is the worst failure this design can produce.
Vanishing is absence, not suppression
An invisible participant is not a visible participant hidden after the fact. It is a participant left out of the shared view entirely, and placed in the overlay of whoever should see them.
The same shape appears in the other direction. Announcing one person to a single observer means leaving them out of the shared view and adding one overlay entry per observer who should receive them.
References stay inside what is already visible
An overlay may refer only to what its connection can already see. A participant placed by an overlay sits in a channel that is either in that same overlay, or in the shared view and visible to that connection. The same holds for the parent of a channel an overlay adds.
One lookup per overlay element rules out three failures in a single stroke: placing someone in a channel about to disappear, placing them in a channel that connection cannot see, and referring to a session that does not exist.
Cost and lifetime
An overlay is never journalled. On each turn it is recomputed, compared against the one last sent to that connection, and the difference joins that connection’s transition.
What is paid is the size of the overlay, not the size of the view. Overlays stay cheap as long as they stay exceptional, which is the same reason a scope with a single observer should have been an overlay to begin with.
Audio as a relation
Audio routing is a directed relation over connections: for each ordered pair, whether the second may receive the first’s voice. Declared outright, and independent of both the channel tree and the scope tree.
The independence is deliberate. “Spectators hear a team without being heard” is not a position in a tree, and forcing audibility to follow a visibility structure is what makes such models accumulate special cases.
Silence by default
Nothing is implicit. With no declaration, no audio is delivered. Sharing a channel grants nothing, and sharing a scope grants nothing.
Three primitives
Domain. A named group whose members all hear each other. Symmetric. A member never receives its own voice back, since the client already plays it locally and echoing it from the server is the classic doubled-voice defect. Members declared repeatedly under one identifier are accumulated rather than replaced, so a domain may be assembled across several loops.
Listen. One connection hears a domain without being heard by it. One way.
Edge. A single directed pair, for whatever the other two do not express.
Cost follows the shape: quadratic in a domain’s size, linear in a listened domain’s size, constant for an edge.
audio_domain(RED, [alice, bob]) alice ◀───▶ bob
audio_listen(carol, RED) alice ────▶ carol
bob ────▶ carol
audio_edge(dave, alice) dave ────▶ alice
Nothing points away from carol, so nobody hears her. Nothing points at dave, so he hears nobody.
Failing closed
A listen naming a domain that was never declared yields nothing. A typo costs silence rather than disclosure.
Mute and deafen
The mute, suppress and deafen flags rendered on a participant are compiled into the routing table rather than tested on each packet. A mute therefore takes effect on the very turn that announces it, since the table is published before any view.
Compiling them in is not only an optimisation. A client showing a crossed-out microphone next to someone whose voice still comes through would be a contradiction produced by the runtime on the application’s behalf.
Seeing the speaker
One constraint crosses over from the protocol: a receiver must see the speaker.
Audio whose sender session is unknown is discarded by the Mumble client, so an edge landing on a receiver that cannot see the sender would deliver nothing. The condition is checked on the render’s output, and a render violating it is refused whole.
A corollary worth stating: there is no separate right to speak. Speaking somewhere implies being visible there. See Seeing the speaker.
The compiled table
The declared relation is compiled once per turn into a table of receivers per speaker, read by the voice plane without a lock and replaced whole rather than mutated. No shard is consulted when a voice packet arrives.
Each speaker in the table carries the version at which it became visible. A receiver whose position in the history has not reached that version is given nothing, which is what keeps a newly granted route inert until the receiver has been told the speaker exists. Revocation needs no such gate: the table is published before any view is pushed, so cutting a route early is always safe.
Deltas and per-connection composition
A shard renders once. What each connection receives is the difference between two consecutive shared views, filtered down to what that connection observes.
application state changes
| wake
v
render ──▶ plan ──▶ journal ──▶ per connection: filter, splice, collapse
The scope belongs to the identity
The comparison key of the diff is the pair of element and scope, not the element alone. That choice costs nothing and removes a family of special cases.
A player changing team moves from /g7/t3 to /g7/t2. Under the pair key this
is not a field that changed. The entry at /g7/t3 disappears and the entry at
/g7/t2 appears, so an ordinary diff produces two operations on its own:
RemoveUser(B) [scope /g7/t3]
AddUser(B, channel Team2) [scope /g7/t2]
The per-connection filter then stays one test with no branches:
| connection | observes | receives |
|---|---|---|
| teammate still in t3 | /g7/t3 | the removal alone, so B leaves |
| player in t2 | /g7/t2 | the addition alone, so B arrives |
| spectator | /g7 | both, settled by collapse |
| player in another match | /g8 | nothing |
A property follows for free: a move inside one scope stays a move, while a move between scopes becomes a departure and an arrival. The identifier does not change, since it is the same person and the client’s local preferences for them have to survive. Only the diff’s notion of sameness carries the scope.
Phase order
The operations of one transition are grouped so that the ordering rules the protocol depends on hold by construction:
P1 CreateChannel parents before children
P2 UpdateChannel
P3 AddUser
P4 MoveUser before any removal, so no occupied channel is deleted
P5 UpdateUser
P6 RemoveUser
P7 RemoveChannel children before parents
Audio produces no operation here. The routing table is separate and its ordering is guaranteed differently, so a plan is purely a sequence of view changes.
The journal
Each delta is appended under a new version number. A connection holds a position in that history and replays the slice it has not seen.
The history is bounded to 256 versions. A connection whose position has fallen below the oldest retained entry cannot be repaired from deltas and is closed; it reconnects from nothing.
No snapshot mechanism exists, and none is needed. A newcomer receives the plan from an empty view to the current one, which is the ordinary planner. A departure is the inverse. A migration is both.
Composition
One connection’s transition is built in four lines:
shared = filter(journal.replay(cursor, head), observed)
private = plan_elements(overlay_sent, overlay_new)
ops = splice(shared, private)
collapse(ops)
Filter keeps the operations whose scope this connection observes. Validity is preserved for free: every ordering rule has the form “X before Y”, and dropping elements from a sequence violates none of them, so a valid plan stays valid after filtering. The closure property does the real work, guaranteeing that no reference is left dangling by the elements dropped.
Splice inserts the overlay’s operations inside the shared phases rather than after them. The counter-example is immediate: with private operations last, a shared delta removing a channel would send that removal before the administrator standing in it is withdrawn, deleting an occupied channel.
P1..P5 shared additions, filtered
├─ overlay removals before: they vacate a channel about to die
└─ overlay additions after: they may target a brand new channel
P6..P7 shared removals, filtered
Collapse drops every removal of an element that is also added in the same transition. One pass covers three situations, because in all three the answer is the same: an element that has an addition somewhere still exists, so the removal is wrong. A full addition carries the element’s complete state, and channel and participant state are merged rather than replaced on the client, so re-sending an element whole amounts to updating it.
| situation | operations produced | after collapse |
|---|---|---|
| the element changes scope | removal, then addition | the addition alone |
| vanished to visible | removal from the overlay, addition to the shared view | the addition alone |
| visible to vanished | removal from the shared view, addition to the overlay | the addition alone |
| dropped everywhere | removal alone | the removal, kept |
What is deliberately not done
Diffing each connection’s composed view against what it currently holds is the honest formulation, and it is always correct. It also costs the size of a full view per connection, which is the quadratic this whole design exists to escape.
Composition instead costs the size of the delta, plus the size of that connection’s overlay, plus the size of the result.
Seeing the speaker
A receiver must see the speaker.
This is the single point where visibility and audibility are not independent, and it does not originate in Mumble Server Runtime.
A protocol constraint
Before buffering a voice frame, the Mumble client looks up the sender’s session in the model it holds. A frame whose sender is unknown is discarded.
An audio edge landing on a receiver who cannot see the sender would therefore deliver nothing. It is not a policy question but a fact about the client, and declaring such an edge is silence with extra steps.
A corollary worth stating: there is no separate right to speak. Speaking somewhere implies being visible there.
Checked on the render’s output
The condition is verified when a render is finished, against what each connection can see. A render declaring an edge that violates it is refused whole, and the previous view stays in place.
Visibility here means either route: through the shared view, when the sender’s scope is comparable to one the receiver observes, or through that receiver’s own overlay. Someone placed by an overlay counts as being there.
Two orderings carry the rule at runtime
The rule holds on the render’s output, but a transition takes time to reach a client, so two orderings keep it true in between.
The routing table is published before any view. Revocation therefore takes effect immediately, which is always safe: cutting a route early costs a lost packet, never a leak.
A granted route stays inert until the receiver has been told. Each speaker in the table carries the version at which it became visible, and a receiver whose position in the history has not reached that version is given nothing. The gate is one comparison on the voice path, with no shard involved.
Control and voice share one queue per connection. The message introducing a speaker is therefore written before that speaker’s tunnelled audio, rather than racing it.
Text inherits the same rule
A recipient who cannot see the speaker is skipped when a message is relayed. A text message naming a session the recipient does not hold would break the same client-side assumption that audio breaks, so the constraint is not specific to voice.
Cost
The model exists for one reason, and the reason is a cost.
The shape being avoided
Rendering the whole world as seen by one connection, once per connection, means N connections each holding a view of size proportional to N. Materializing every view then costs N squared, and no scheduler and no cache change that, because the cost is in the shape of the operation rather than in its implementation.
A shard renders once instead. Each fact is held once, whoever ends up seeing it, and what connections receive is the resulting difference, filtered.
What one turn costs
Writing W for the size of the shared view, N for attached connections and
|D| for the size of the delta:
per shard, per turn =
O(W) the shared render, once
+ O(N × |D|) the filter, one pass per connection
+ Σ |overlay| the individual exceptions
+ O(W) per connection whose observation changed
+ O(N + edges) when the audio relation changed
What costs is the size of each difference, not the number of connections
receiving one. A three-operation delta filtered N times costs O(N × |D|), not
O(N × W), and the property holds even when every view differs. That is what
makes it robust rather than merely fast.
Per mechanism
| mechanism | cost |
|---|---|
| shared view and scopes | O(W) once, plus O(|D|) per connection |
| private overlay | the size of the overlay |
| audio relation | O(N + edges) |
The audio primitives differ among themselves: a domain is quadratic in its own size, a listen is linear in the size of the domain listened to, an edge is constant.
Where the bounds come from
The fixed limits in the model are all consequences of this cost, not matters of taste.
Scope depth is bounded because a scope is a copied value consulted once per connection per turn, and a heap-allocated path would put an allocation on the composition path. The number of scopes one connection observes is bounded for the same reason, and because an unbounded observation restores the quadratic term directly.
The journal is bounded because it is memory held for connections that have fallen behind. Past that distance a connection is unrecoverable from deltas anyway.
The number of context actions offered to one connection is bounded because a turn enters that connection’s queue in one piece, and an oversized batch would close the connection rather than fail inside application code.
The known spike
A change of observation costs a full replan for the connection concerned. When
many observations change at once, at the start of a round for instance, the term
that is normally zero becomes O(W) per affected connection.
Two things bound it. Renders are coalesced under a minimum interval, so a burst
of state changes yields one turn rather than many. And sharding per match keeps
W to one match rather than one server.
Refusing to trade the property away
Diffing each connection’s composed view against what it holds is correct and
obvious, and it costs O(N × W). It is the formulation this design exists to
avoid, and no optimisation recovers what adopting it gives up.
Refusals
Nothing a client sends changes voice state on its own, and nothing incoherent is sent to a client. Both properties come from refusing early, in three distinct places.
Client events are statements, never commands
An event reported to the application describes what a client asked for: connecting, disconnecting, requesting entry to a visible channel, requesting self-mute or self-deafen, invoking a menu entry the application itself defined, sending a message.
No voice state is changed by delivering one. Application state is updated when a request is accepted, and the consequence appears through the next render. Ignoring an event entirely is a valid answer.
A refusal therefore consists of rendering nothing new. A message may be returned to the connection alongside it, which reaches the client as a denial carrying a reason, but the message itself changes nothing.
Events arrive pre-resolved and pre-validated
An event is expressed in the application’s own vocabulary, using channel keys and occupants rather than wire numbers, and it has already been checked against what that connection can see.
A request naming a channel that connection cannot see is dropped and logged rather than answered, so a guessed number cannot be used to test whether something exists. An invoked menu entry is checked against what the client actually holds rather than what the last render intended, and against the places the entry was declared for. A message aimed at a visible but read-only channel is refused out loud.
A refused render keeps the previous view
A render is accepted or discarded whole. Nothing partial reaches a client, and a refusal closes no connection: the committed view is still correct, and reconnecting would only reproduce the same failure.
The refusals fall into four families.
Structure. No root, two roots, one channel key or one occupant rendered twice, a link between channels whose scopes are not comparable, identifiers exhausted.
Scope. Narrowing past the maximum depth. Refused rather than clamped, since clamping would hand the element its parent’s visibility.
Shared against private. A channel or a participant present both in the shared view and in an overlay. An overlay placing someone in a channel absent from the render, or in a channel that connection cannot see.
Audio. An edge whose receiver cannot see the sender.
More context actions than fit in one transition is also refused, for a mechanical reason: the whole turn enters the connection’s queue in one piece, and an oversized batch would close the connection instead of failing inside application code.
Failing closed
Where a declaration is incomplete, the result is silence rather than exposure. A listen naming a domain that was never declared yields no route. An unimplemented path returns an explicit denial rather than doing something approximate.
The bias is deliberate and consistent: a mistake costs a missing feature, never a leak.
Building an application
How to write one. Types, signatures and code appear throughout, and the generated API documentation carries the detail this chapter leaves out.
Running the arena needs nothing but a Mumble client and comes first for that reason. Anatomy of an application is the shortest path to understanding what the demonstration is made of.
The pages after those two follow the order the work itself takes: rendering a view, deciding where a distinction belongs, declaring who hears whom, answering what clients do, moving connections between shards, and measuring the result.
Terms used here without definition are defined in The model.
The example to read
Every code fragment in this chapter is drawn from the demonstration
application, which lives under tools/mumble-server-runtime-arena/ and builds
with the workspace.
| file | what to take from it |
|---|---|
src/main.rs | composition: gateway, two shards, an external update task |
src/lobby.rs | the simplest shard that can exist, and a migration request |
src/arena.rs | scopes, overlays and the audio relation, one purpose each |
src/router.rs | where an arrival goes, and what a credential buys |
src/directory.rs | state shared between shards, and shard discovery |
tests/arena.rs | driving a shard turn by turn without sockets |
About 1200 lines of application code and 700 of tests, and the only crate in the repository naming both the runtime and a concrete business. Copying its shape is a reasonable way to start.
Running the arena
The arena is the demonstration application shipped with the runtime. It is a lobby and a match running as two shards, and it is the only crate in the repository that names both the runtime and a concrete business. Everything below it is generic, everything inside it is a game.
Each of the three visibility mechanisms is used there for exactly one thing, so that reading the code answers which mechanism fits which problem:
- teams as scopes, so a red player cannot see that blue exists,
- a vanished admin as a private overlay,
- spectators as one-way listeners, hearing both teams and heard by neither.
Everything is driven from a stock Mumble client by double-clicking channels.
lobby arena
Arena The Arena
|- Red Team |- Red Base scope /1
|- Blue Team |- Blue Base scope /2
|- Spectators |- Observation Deck scope /3
`- > Enter the Arena ----------> |- Neutral Ground scope /
<---------- `- < Back to the Lobby scope /
Starting it
$ cargo run -p mumble-server-runtime-arena
Two optional positional arguments follow, in order:
| argument | default | meaning |
|---|---|---|
| bind address | 0.0.0.0:64738 | where the gateway listens |
| admission ceiling | 100 | how many connections are accepted |
$ cargo run -p mumble-server-runtime-arena -- 127.0.0.1:64738
The address is worth overriding on a machine already running a Mumble server, which holds port 64738.
A self-signed certificate is generated at every start. A real deployment supplies a persistent one instead, otherwise each restart looks like a new server to clients, which key their local preferences on the certificate hash.
Connecting
Point a Mumble client at 127.0.0.1, port 64738. Use the address rather than
localhost, which resolves to IPv6 first and finds nothing listening.
A name is required: a connection arriving without one is rejected by the router
with a reason. Typing overwatch into the password field joins as staff.
That credential is a placeholder for a real token flow, kept deliberately obvious. What matters is its shape. An opaque credential is carried by the gateway, and what it buys is decided by the application alone.
The lobby
Every arrival is attached to the lobby. It is one flat room where everyone sees and hears everyone, and it uses no scopes at all: there is one group, and reaching for a mechanism because it exists would be the mistake the crate is meant to illustrate.
Four channels are offered, and double-clicking one states an intent rather than performing a move:
| channel | effect |
|---|---|
| Red Team | chooses the red side |
| Blue Team | chooses the blue side |
| Spectators | chooses to watch |
> Enter the Arena | requests migration to the arena shard |
The same door is also offered as a context menu entry, per connection rather than shared. A connection that has chosen no side is told so when the entry is invoked, which a channel cannot do.
The root channel name carries a counter incremented once per second from outside the shard. It demonstrates the path business work takes to reach a render: the producer sends an update on a channel it owns, wakes the shard, and the pending updates are drained during the next render.
The arena
Five channels, three of which are at distinct scopes:
| channel | scope | visible to |
|---|---|---|
| Red Base | /1 | red players, spectators, staff |
| Blue Base | /2 | blue players, spectators, staff |
| Observation Deck | /3 | spectators, staff |
| Neutral Ground | root | everyone in the arena |
< Back to the Lobby | root | everyone in the arena |
scope tree observed by sees
/ unplaced arrival /
|
+- /1 red player /, /1
|
+- /2 blue player /, /2
|
+- /3 spectator, staff /, /1, /2, /3
Red Base is not hidden from a blue player by a filter that could be given the wrong side of. It sits at a scope their observation is not comparable to, so it is absent from every delta they will ever receive. One render produces both teams’ views.
Neutral Ground and the door back to the lobby stay at the root scope, which is what makes them the only two things every occupant can act on.
Every action a player takes is a channel request. Stepping onto Neutral Ground turns a player into a spectator, which widens their observation to the whole tree; picking a base from there narrows it again. Both directions are reachable by hand from a stock client.
The three mechanisms, side by side
Teams are scopes. A player observes one team scope. Membership of the other team is not filtered late, it is never planned.
Staff are an overlay. Staff occupy no shared channel. Their only presence is in overlays: their own, which is what lets the client find itself when the handshake names its session, and, when a team is addressed, that team’s. The vanish is an absence rather than a flag, so there is nothing downstream to filter out.
Hearing is a relation. Spectators sit at their own scope and listen to both team domains. One-way is what listening is, rather than a rule someone has to remember. Staff listen to everything and are heard by nobody until an explicit edge is opened.
Opening that edge and adding the overlay entry are one decision, not a convention. A render placing an audio edge onto a receiver who cannot see the sender is refused, because the Mumble client discards audio from a session it does not know. See Seeing the speaker.
Text
Text is routed by the same rules as everything else, and the two shards make opposite uses of that.
The lobby is one group, so whatever is typed is relayed in one line. The arena refuses anything aimed outside the place the writer is heard in. Every recipient who cannot see the writer would have been dropped anyway, and a message that evaporates silently is worse than a refusal stating why.
Both doors, > Enter the Arena and < Back to the Lobby, are declared
read-only. The client greys their chat box out rather than offering a box whose
answer is a denial.
What to try
Two clients are enough for the scope mechanism. Pick different teams in the lobby, enter the arena, and neither base is visible to the other side.
A third client connected with the staff credential disappears from every view but its own, hears both teams, and becomes visible and audible to exactly one team the moment that team’s base is double-clicked.
Double-clicking > Enter the Arena migrates a connection from the lobby shard
to the arena shard. The TCP connection, the session and every local client
setting attached to it are preserved. The view is transitioned onto what the
client already holds rather than rebuilt, so no reconnection occurs.
Where the code is
| file | contents |
|---|---|
tools/mumble-server-runtime-arena/src/main.rs | the composition: gateway, both shards, the update task |
.../src/router.rs | where an arrival goes and what the credential buys |
.../src/lobby.rs | the flat room, the intents, the migration request |
.../src/arena.rs | scopes, overlays and the audio relation |
.../src/directory.rs | application state shared by both shards |
Anatomy of an application
An application built on the runtime consists of four parts:
- application state, owned entirely by the application and never inspected by the runtime,
- one or more shards, each an implementation of
ShardLogicdescribing what a voice session should look like given that state, - a router, deciding where an arriving connection is attached,
- a composition binary, wiring the above onto a gateway.
Only the last three name runtime types. The first is whatever the application already is.
Two crates
mumble-server-runtime-shard renders, plans and composes. It never learns what
a socket is. mumble-server-runtime-gateway is everything the shard crate
deliberately does not know: TLS, TCP, UDP, the handshake, the connection
registry and migration.
TCP+TLS -> connection task -> router.route() -> Attach(shard)
| |
| control frames | commands, wake
v v
output queue <------------------- the shard's task
UDP -> voice plane -> bindings -> the shard's routing table
(no shard task is involved, ever)
The two planes meet at exactly two places: a routing table published by the shard and read without a shard’s help, and a per-connection cursor the shard advances and the voice plane compares against. A shard task never awaits IO, and the voice plane never waits for a shard.
ShardLogic
Three methods, and the split between them is the interface.
#![allow(unused)]
fn main() {
pub trait ShardLogic: Send + 'static {
fn render(&mut self, out: &mut ShardBuilder<'_>);
fn observation(&mut self, connection: ConnectionId) -> ScopeSet;
fn observe(&mut self, event: &VoiceEvent, out: &mut Reply);
}
}
render builds the shared view, the private overlays and the audio
relation. It takes no viewer parameter, which is the point: what is shared
cannot depend on who is looking. It is synchronous, and must neither await nor
block.
observation answers what one connection sees of the shared view. Called
once per connection per turn, so the returned value stays a small Copy value.
Returning something larger is how the cost goes quadratic again.
observe receives a voice fact and decides what to do with it. The Reply
is the only channel through which the logic speaks. Everything it wants to
change belongs to the next render instead.
The trait is Send + 'static and deliberately not Sync. The runtime never
shares the logic, so it imposes no synchronization.
One turn
application state changes
| wake()
v
+------------------------------------------------------+
| the shard's task (woken, one per shard) |
| |
| 1. logic.render(&mut builder) |
| -> the SHARED view + the PRIVATE overlays |
| + the AUDIO relation |
| 2. ops = plan(current view -> new view) |
| 3. version += 1, ops -> journal |
| 4. publish the audio routing table |
| 5. for each connection: |
| its observation changed? -> replan for it |
| otherwise -> filter, splice, collapse, encode, |
| push, advance its committed state |
+------------------------------------------------------+
Two orderings in that loop are load-bearing rather than incidental. The routing table is published before the views are pushed, so a revoked route disappears immediately. And the committed state of a connection is a quadruplet, cursor, observation, overlay and offered actions, whose four parts advance together once the queue has accepted everything.
A render that fails validation never replaces the current view. It is logged, the previous view is kept, and nothing is closed. See Refusals.
Consecutive turns are separated by at least 50 ms. Several wake-ups arriving inside that window are coalesced into one turn.
Reaching a render from outside
wake carries no data. It states only that the desired state may have changed.
The transport for application vocabulary belongs to the application, which keeps
its receiving end inside the logic and hands the sending end to whatever
produces events.
#![allow(unused)]
fn main() {
struct GameLogic {
inbox: mpsc::Receiver<GameEvent>,
state: GameState,
}
impl ShardLogic for GameLogic {
fn render(&mut self, out: &mut ShardBuilder<'_>) {
while let Ok(event) = self.inbox.try_recv() {
self.state.apply(event);
}
self.state.render(out);
}
// observation, observe
}
}
The producer publishes first, then wakes:
#![allow(unused)]
fn main() {
async fn publish(
notification: Notification,
sender: &mpsc::Sender<GameEvent>,
handle: &ShardHandle,
) -> Result<(), mpsc::error::SendError<GameEvent>> {
let event = calculate(notification).await;
sender.send(event).await?;
handle.wake();
Ok(())
}
}
Publishing before waking is what guarantees the next render observes the change.
Any .await belongs on the producer side, outside the shard task.
An ordered stream of events suits an mpsc::Receiver. State where the last
value wins suits a watch::Receiver holding an immutable snapshot. Either way
the channel remains the source of truth, since wake-ups may be coalesced.
Routing
A router answers where an arrival begins, not where a player belongs. The second question is the logic’s, and its answer is a migration.
#![allow(unused)]
fn main() {
pub trait ConnectionRouter: Send + Sync + 'static {
fn route(
&self,
connection: ConnectionId,
identity: &ConnectionIdentity,
) -> impl Future<Output = RouteDecision> + Send;
}
}
ConnectionIdentity carries a name, an optional certificate hash and an opaque
credential. Everything in it is a claim except the certificate hash, and what
any of it buys is decided by the application. RouteDecision is either
Attach(ShardId) or Reject(String), where the string reaches the client.
Routing runs on the connection’s own task, so it may await a database or an authentication service without holding up anything else. The cost is borne by the arriving client alone.
Routing is also the only moment where a ConnectionId and the claim behind it
meet. Later on, a VoiceEvent carries the identifier and nothing else, since
the runtime holds no opinion on what a user is. An application whose logic needs
a name records the pair here, in a table of its own.
AlwaysAttach(shard) is the honest default for a runtime with one entry point.
Composition
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let identity = Identity::self_signed(vec!["localhost".to_owned()])?;
serve(GatewayConfig::default(), identity, |runtime| {
let lobby = runtime.create_shard(|_handle| Lobby::new());
AlwaysAttach(lobby.shard())
})
.await
}
The closure runs after the sockets are bound and before the first connection is accepted. That window is the only place where “the initial shards exist before anyone can be routed to one” holds.
create_shard takes a closure rather than a value because a logic often wants
its own ShardHandle, to wake itself from work it starts. The closure receives
that handle.
Gateway::bind followed by Gateway::serve is available when the address needs
inspecting between the two, as when binding an ephemeral port in a test.
For a worked example using two shards, an external update task and a real router, see Running the arena.
The render function
render describes what the voice session should look like now, given the
application’s current state. It is called after a wake, at most once every
50 ms, and it builds a whole desired state rather than a change to one.
#![allow(unused)]
fn main() {
fn render(&mut self, out: &mut ShardBuilder<'_>);
}
Nothing is emitted from inside it. The difference against what each connection currently holds is computed afterwards, by the runtime. See Deltas and per-connection composition.
There is no viewer parameter. What is shared cannot depend on who is looking, and what one connection alone sees is declared separately, as an overlay.
The tree
#![allow(unused)]
fn main() {
let root = out.root("The Arena");
let red = out.channel(root, RED_BASE, "Red Base", Narrow::Into(1));
let alice = out.user(red, Occupant::Connection(who), "alice", Narrow::Same);
}
A channel demands a parent and a user demands a channel. In both cases the scope
is derived from the parent’s through Narrow, which either keeps it or extends
it by one segment:
#![allow(unused)]
fn main() {
pub enum Narrow {
Same,
Into(u32),
}
}
There is no free scope parameter anywhere, and no Widen variant. That absence
is the whole safety argument. A user always ends up at or below its channel’s
scope, so any observer seeing the user also sees the channel. The closure
property becomes something to rely on rather than something to check. See
The shared view and scopes.
root is called exactly once per render. Calling it twice is an error, because
a shard renders one tree.
Identity is stated, not derived
ShardBuilder::channel asks for a ChannelKey. The diff has to recognise the
same channel across two renders, and an identity that drifts burns a wire
identifier every turn.
Deriving identity from the name is the obvious guess and the trap. A channel whose name carries a clock would be destroyed and recreated ten times a second. The key is therefore stated explicitly and the name stays a field:
#![allow(unused)]
fn main() {
const RED_BASE: ChannelKey = ChannelKey(10);
let name = format!("Red Base ({} players)", red.len());
let red = out.channel(root, RED_BASE, &name, Narrow::Into(1));
}
Rendering the same key twice in one turn is an error, not a merge.
ActionKey follows the same rule for the same reason: the wire identifier is a
string the client stores and echoes back, so an application using the label as
the identity would break its own buttons the day it renames one.
Users need no separate key. Occupant is the identity.
Channel attributes
| call | effect |
|---|---|
channel_position | ordering hint sent to the client |
channel_can_enter | whether the client offers the channel as enterable |
channel_can_text | whether the client offers a chat box for it |
channel_link | symmetric link between two channels |
user_flags | mute, deafen, suppress, priority speaker, recording |
can_enter is a display hint. An actual join arrives as an event and is
validated again then. Declaring can_text false greys the chat box out rather
than offering a box whose answer is a denial, and a message aimed there is
refused anyway.
A link is the one relation that does not follow the parent hierarchy, which makes it the only structural check in the model. Linking two channels at non-comparable scopes is refused, since a link pointing at a channel the viewer cannot see has no meaning.
Private elements
Elements visible to one connection are declared inside a private block:
#![allow(unused)]
fn main() {
out.private(admin, |private| {
let room = private.channel(root, overwatch_key, "Overwatch");
private.user_in(room, Occupant::Connection(admin), "admin (vanished)");
private.action(JOIN, "Enter the Arena", On::SERVER);
});
}
A private element is never scope-filtered. Its visibility is the block it was declared in.
Overlays are recomputed at every render and never journalled, so an element is withdrawn by not declaring it again. A context action works the same way: the difference against what the connection was already offered is what travels, and no message is emitted by the application.
Whether a distinction belongs in a scope or in an overlay is the subject of Scope or overlay.
Audio
Three calls, and the relation they build is directed and independent of both trees:
#![allow(unused)]
fn main() {
out.audio_domain(RED_VOICE, &red_members); // symmetric group
out.audio_listen(spectator, RED_VOICE); // one-way
out.audio_edge(admin, member); // a single directed edge
}
See Declaring audio.
What closes the render
Three properties are not enforced by the builder’s shape and are checked when the render closes:
- Shared or private, never both. An element in the shared view and in an overlay would drift: the next shared delta would move it without the overlay reasserting itself, and the client would diverge silently.
- An overlay references only what its connection already sees. One lookup per overlay element forbids, in one stroke, placing someone in a channel about to vanish, in a channel that connection cannot see, or referencing a session that does not exist.
- A receiver sees its sender. The Mumble client discards audio whose sender session it does not know, so an edge into a blind receiver is silence with extra steps. See Seeing the speaker.
The failures a render can report:
| error | cause |
|---|---|
MissingRoot, DuplicateRoot | no tree, or two |
DuplicateChannelKey, DuplicateOccupant | the same identity rendered twice |
ScopeTooDeep | more than four segments |
LinkAcrossScopes | a link between non-comparable scopes |
SharedAndPrivateUser, SharedAndPrivateChannel | the first property above |
OverlayChannelMissing, OverlayChannelInvisible | the second |
ReceiverCannotSeeSender | the third |
TooManyActions | more than 64 offered to one connection |
Exhausted | no channel or session identifier left |
A refused render keeps the previous view and closes nothing. The committed view is still correct, and reconnecting would only reproduce the same broken render.
Scope or overlay
A scope describes a group, an overlay describes an individual exception. That is the rule, stated in The shared view and scopes and Overlays. This page is about applying it.
The two mechanisms answer different questions
The name of this page is slightly misleading, and the confusion is worth clearing first. Scopes and overlays are not two ways of doing one thing.
A scope answers what does this connection see. That answer is always a
scope, returned from observation, and there is no alternative mechanism for
it.
An overlay answers who else sees this element, for an element that would otherwise be shared. Its alternative is the shared view, not a scope.
The demonstration application makes the split visible. Staff observe three scopes, the same set spectators observe, so what they see is expressed in scopes. Their own presence is rendered into no shared channel at all, only into overlays. One connection, both mechanisms, each for its own question.
#![allow(unused)]
fn main() {
fn observation(&mut self, connection: ConnectionId) -> ScopeSet {
match self.roles.get(&connection) {
Some(Role::Player(side)) => ScopeSet::new(&[side_scope(*side)]),
Some(Role::Spectator | Role::Admin { .. }) => watcher_view(),
None => ScopeSet::new(&[Scope::ROOT]),
}
.unwrap_or(ScopeSet::NONE)
}
}
Placing an element
Once observation is settled, each element rendered is either shared, and therefore seen by everyone whose observation reaches its scope, or private to the connections whose overlay carries it.
| situation | placement |
|---|---|
| team channels and their members | shared, at the team’s scope |
| a spectator watching both teams | shared, at a scope of its own |
| a vanished administrator | overlay, their own |
| an administrator addressing one team | one overlay entry per member of that team |
| a private channel one person holds | overlay |
| a per-connection button | overlay, as a context action |
| proximity voice | neither: an audio relation, with no structural change |
The last row matters more than it looks. Audibility is a third mechanism, not a consequence of the first two, so a distinction that is purely about who hears whom needs no scope and no overlay. Reaching for one is how special cases start. See Audio as a relation.
When a scope is an overlay in disguise
A scope with one observer is an overlay in disguise. A role is a group by nature even while it holds a single member: player, host, spectator, staff. The test is not the current headcount but whether a second member would be a change of data or a change of model.
Two bounds turn that judgement into a hard limit: a scope is at most four segments deep, and a connection observes at most four scopes. Running out is a modelling signal rather than a configuration problem. A design needing a fifth observed scope is usually describing individuals with a group mechanism.
Cost
| mechanism | cost |
|---|---|
| shared view and scopes | O(W) once, plus O(|D|) per connection |
| overlay | O(|overlay|) per connection, every turn |
| audio relation | O(N + edges) |
A scope is rendered once whatever the number of observers. An overlay is recomputed for its connection at every turn and never journalled, so nothing amortises it.
That asymmetry is the whole argument. Overlays stay cheap while they stay exceptional. An overlay whose size grows with the population is the quadratic shape the model exists to avoid, arriving through the one door left open. See Cost.
A worked decision
A game wants an administrator who is invisible, hears everything, and can address one team at a time.
Observation. The administrator has to see both teams to address either, so their observation is the watcher set. A scope, like every observation.
Presence. Invisible means absent from the shared view rather than present and suppressed. Nothing is rendered for them in any shared channel. Their own client still needs to find itself when the handshake names its session, so one overlay carries their presence to themselves alone.
Hearing. One-way listening on both team domains. No structural change.
Addressing a team. Two declarations that cannot be separated: an overlay entry giving each member of that team sight of the administrator, and a directed audio edge to each. Declaring the edge alone builds a render the runtime refuses, because a receiver who cannot see the sender is a receiver whose client discards the audio. See Seeing the speaker.
Declaring audio
Audibility is declared inside render, alongside the tree, through three calls:
#![allow(unused)]
fn main() {
out.audio_domain(RED_VOICE, &red_members); // symmetric group
out.audio_listen(spectator, RED_VOICE); // one way
out.audio_edge(admin, member); // one directed pair
}
What they build is a directed relation over connections, independent of the channel tree and of the scope tree. Audio as a relation defines it. This page covers declaring one.
The relation is rebuilt every render
Silence is the starting point of every render, not a state that persists between them. Nothing carries over, so a route is withdrawn by not declaring it again.
A DomainId therefore needs no stability across renders, unlike a ChannelKey.
It is a label within one render, used to connect a group to its listeners, and
nothing compares it against the previous turn.
#![allow(unused)]
fn main() {
const RED_VOICE: DomainId = DomainId(1);
}
Constants are still the readable choice, but a domain identifier computed from a match number would be equally correct.
Choosing among the three
| intent | declaration |
|---|---|
| a group whose members all hear each other | one audio_domain |
| an observer hearing a group without being heard | audio_listen |
| one person addressing another | audio_edge |
| anything the first two do not express | audio_edge |
A domain expresses a full mesh in one declaration. The pair loop is never written by the application, which is the difference between describing a group and materialising it.
A listen naming a domain that was never declared yields nothing. A typo costs silence rather than disclosure.
Assembling a domain across loops
Repeated calls under one identifier accumulate members rather than replacing them, so a domain may be built wherever the members happen to be iterated:
#![allow(unused)]
fn main() {
for (connection, role) in &self.roles {
if let Role::Player(side) = role {
out.audio_domain(voice_of(*side), &[*connection]);
}
}
}
Mute and deafen are not audio declarations
The mute, suppress and deafen flags are rendered on the participant, through
user_flags, and compiled into the routing table from there:
#![allow(unused)]
fn main() {
let user = out.user(red, Occupant::Connection(who), &name, Narrow::Same);
out.user_flags(user, flags);
}
Declaring a flag and then omitting the corresponding audio declaration would say the same thing twice, and the two statements would eventually disagree. A muted participant stays in their domain.
An edge and its overlay travel together
Every declared route requires the receiver to see the sender. A render placing an edge onto a receiver who cannot is refused whole.
In practice this pairs one audio declaration with one visibility declaration:
#![allow(unused)]
fn main() {
for member in self.members_of(side) {
out.private(member, |private| {
private.user_in(base, Occupant::Connection(admin), &label);
});
out.audio_edge(admin, member);
}
}
Writing the edge without the overlay entry is the most common way to build a render the runtime refuses. The two are one decision. See Seeing the speaker.
Proximity
Distance-based audibility is the one shape where the number of edges grows with the population, and that is inherent rather than a limitation of the model:
#![allow(unused)]
fn main() {
for (speaker, position) in self.positions() {
for listener in self.within(position, HEARING_RANGE) {
out.audio_edge(speaker, listener);
}
}
}
Everyone involved has to be mutually visible for those edges to mean anything, so proximity voice is usually rendered as one shared channel holding every participant in range of anyone, with the relation doing the selection. The alternative, a scope per neighbourhood, exhausts the four observed scopes as soon as neighbourhoods overlap.
What not to do
Do not enumerate pairs where a domain applies. The relation has a resolve
operation materialising every pair, and no shard turn calls it. It exists to
state plainly what the declarations mean, and a test pins the compiled table to
it. Turning it into a code path reintroduces the quadratic shape the model
exists to avoid.
Do not attempt to grant a right to speak separately. There is none. Speaking somewhere implies being visible there.
Client interactions
Actions taken in the client are reported to the application through observe:
#![allow(unused)]
fn main() {
fn observe(&mut self, event: &VoiceEvent, out: &mut Reply);
}
None of them changes any voice state on arrival. Refusals explains why. This page covers handling them.
Two doors
render states what is. Reply states what is said. That split is why
the trait has three methods rather than one per feature.
A state belongs to the render, which restates it every turn until it stops being true. A word is said once, at a date, and no later render can restate it.
What arrives
VoiceEvent is non-exhaustive, so a match on it needs a fallback arm.
| event | meaning |
|---|---|
Connected | a connection was attached to this shard |
Disconnected | gone, with a reason. No further event carries it |
Migrated | left for another shard. The socket and session are intact |
RequestedChannel | a channel was double-clicked or dragged into |
RequestedSelfState | the client asked to mute or deafen itself |
InvokedAction | a context action offered to this connection was invoked |
Said | text was typed and aimed somewhere |
Disconnected and Migrated mean opposite things and are therefore separate. A
disconnect frees a slot, a migration hands it over.
Everything arrives resolved
Events carry the application’s own vocabulary, ChannelKey and Occupant,
never the wire identifiers a client holds.
They are also pre-validated. The channel named by RequestedChannel is one that
connection can actually see. The key in InvokedAction was offered to it, the
target is visible to it, and the place matches the declared bits. The audience
in Said is resolved against what the sender sees, and the channel it names was
rendered as writable.
What remains is entirely the application’s decision, up to and including doing nothing.
Answering
| verb | effect |
|---|---|
say(to, text) | tell one connection something, in the server’s name |
refuse(to, reason) | a refusal, shown where the client reports denials |
relay(from, to, text) | deliver text to an audience, attributed to a sender |
announce(to, text) | the same, in the server’s name |
switch(connection, shard) | hand the connection to another shard |
An Audience is Channel(key), Tree(key) for a channel and everything below
it, or User(occupant).
A Reply accumulates and is drained once observe has returned, so a test can
drive observe with a scratch reply and read back what came out.
Accepting a request
Accepting means changing application state. Nothing else:
#![allow(unused)]
fn main() {
VoiceEvent::RequestedChannel { connection, channel } => {
match *channel {
RED => self.chosen.set(*connection, Intent::Join(Side::Red)),
BLUE => self.chosen.set(*connection, Intent::Join(Side::Blue)),
_ => {}
}
}
}
The next render carries the consequence. No wake is needed: handling an event already marks the shard for reconciliation.
Refusing
Refusing means rendering nothing new. Saying so is separate, and worth doing:
#![allow(unused)]
fn main() {
VoiceEvent::InvokedAction { connection, action, .. } if *action == JOIN => {
match self.chosen.get(*connection) {
Some(_) => out.say(*connection, "Entering the arena."),
None => out.refuse(*connection, "Choose a side first."),
}
}
}
A silent refusal leaves someone pressing a button that does nothing, which is
the state refuse exists to end.
Text
Said delivers nothing by itself. relay is the one line that carries it out,
and the application stays in charge of the audience. Relaying somewhere other
than where a message was aimed is a rewrite rather than a workaround.
#![allow(unused)]
fn main() {
VoiceEvent::Said { connection, to, text } => {
out.relay(*connection, *to, text);
}
}
Two rules are applied when the audience is expanded, both borrowed from elsewhere in the model. The sender never receives its own message. A recipient who cannot see the sender is skipped, which is the audio coupling rule applied to text.
announce has no actor, so nobody is skipped for not seeing one. It is the
right verb for something everyone should read whoever said it.
Self-mute and self-deafen
RequestedSelfState carries two Option<bool>. None means the client said
nothing about that flag, so whatever is currently rendered stands.
The runtime keeps no copy of the pair. An application granting the request
stores it and hands it back through user_flags on the next render. Keeping a
second copy in the runtime is how the two start disagreeing.
Moving a connection
#![allow(unused)]
fn main() {
out.switch(connection, arena);
}
Anything said in the same breath is delivered first, so a farewell reaches the socket before the move is asked for. See Migration between shards.
Migration between shards
A migration hands one connection from one shard to another without interrupting it. The client keeps its TCP connection, its TLS session, its session identifier and every local setting attached to them. The world changes around it.
Asking for one
#![allow(unused)]
fn main() {
out.switch(connection, arena);
}
Called from observe, like every other answer. It records an effect the runtime
carries out, since orchestrating two shards is beyond what one shard can do. A
shard belonging to no runtime says so rather than pretending the move happened.
Anything said in the same breath is delivered first, so a farewell reaches the socket before the move is asked for.
The order
source shard runtime destination shard
Detach { handover } ──────────▶
compose the held view
the client's view ──────────▶
move the routing table
──────────▶ Attach { held }
plan one transition
Three orderings are load-bearing.
The held view is composed by the source and handed over before the destination plans anything. Without it the destination would be planning from a guess. If the source is gone, the connection is closed instead, because what the client holds is then unknown.
The routing table follows the connection before its view does. A route it is no longer entitled to disappears immediately, and a route it gains stays inert until its cursor catches up. Cutting early is always safe, the reverse never is.
Context actions the source offered are withdrawn as it lets go. An action belongs to the shard that offered it, and the destination starts from an empty registry. Otherwise the client would keep buttons nobody can withdraw, and invoking one would reach a shard that never granted it.
Why not a disconnect and a reconnect
Tearing down first does not merely waste work, it disconnects the official client.
The client keeps itself in its own model after a UserRemove naming its own
session. The ChannelRemove that follows therefore looks to it like the server
removing an occupied channel, which it treats as a protocol violation.
Attaching is an ordinary scope change
The destination receives the connection observing nothing and holding a view some other shard produced. The next turn takes the slow path, sees a connection holding a view this shard did not produce, and plans one transition onto its own tree.
That is the same path a scope change takes, so attach, detach and migrate are one mechanism rather than three. Whatever the two trees have in common does not flicker, and the no-flicker property comes free rather than being maintained.
What each side observes
The source receives Migrated { connection, to }. The destination receives
Connected { connection }.
Migrated is deliberately not Disconnected. A disconnect frees a slot, a
migration hands it over, and an application that conflates them will decrement a
count it should have transferred.
Carrying state across
A shard learns nothing about a connection beyond its ConnectionId. State that
has to survive a move therefore lives in the application, shared by both shards:
#![allow(unused)]
fn main() {
let directory = Arc::new(Directory::new());
let destinations = Arc::new(Destinations::new());
let lobby_directory = Arc::clone(&directory);
let lobby_destinations = Arc::clone(&destinations);
let lobby = runtime.create_shard(move |_handle| {
Lobby::new(lobby_directory, lobby_destinations)
});
let arena_directory = Arc::clone(&directory);
let arena_destinations = Arc::clone(&destinations);
let arena = runtime.create_shard(move |_handle| {
Arena::new(arena_directory, arena_destinations)
});
}
The demonstration application records a name and a staff flag in the router, and the side a player picked in the lobby. The arena reads both, and neither shard holds a reference to the other’s logic.
Learning the destination
switch names a ShardId, so two shards that can send connections to each
other have to learn the other’s identifier. Neither exists when the first is
built, which is the whole difficulty.
A write-once cell is the smallest thing that resolves it without a placeholder that could be read before it is filled:
#![allow(unused)]
fn main() {
destinations.set_lobby(lobby.shard());
destinations.set_arena(arena.shard());
}
The logic reads it back when an event calls for a move, and treats absence as a refusal rather than an impossibility:
#![allow(unused)]
fn main() {
match self.destinations.arena() {
Some(arena) => out.switch(connection, arena),
None => out.refuse(connection, "The arena is not running yet."),
}
}
Both ends have to exist
A move naming a shard that does not exist is refused and logged, and the
connection stays where it is. Creating every shard before the first connection
is accepted avoids the case, which is what the closure passed to serve is for.
Filling the cells right after creating both shards keeps the refusal branch
above unreachable in a composed binary, and correct if the composition ever
changes.
Scope
Shards are units of rendering inside one runtime, not separate machines. Moving a connection between them is implemented and covered by tests. Spreading shards across hosts behind voice proxies is designed and not built.
Configuration and limits
There is no configuration file and no environment variable. Everything is Rust code, and the values that are not code are constants rather than settings.
The reason is in Boundaries: everything an application would want to vary at runtime, names, trees, who hears whom, belongs to the render. What is left is the handful of values the Mumble handshake demands before any view exists.
GatewayConfig
#![allow(unused)]
fn main() {
let config = GatewayConfig {
bind: "0.0.0.0:64738".parse()?,
welcome_text: "Arena. Double-click a channel to choose.".to_owned(),
max_users: 500,
..GatewayConfig::default()
};
}
| field | default | effect |
|---|---|---|
bind | 0.0.0.0:64738 | TLS control plane. UDP binds the same port |
welcome_text | empty | sent in ServerSync. Empty omits the field |
max_bandwidth | 72_000 | advertised bits per second |
max_users | 100 | advertised, and the admission ceiling |
recording_allowed | true | advisory |
allow_html | true | whether HTML is allowed in text |
message_length | 5_000 | maximum text message length |
version | (1, 5, 0) | advertised version |
Two of these are advertised rather than enforced. The bandwidth ceiling is sent to clients and not applied server-side. The recording flag is a request the client is free to ignore.
max_users is the exception among the advertised values: connections past it
are refused, rather than admitted into a runtime that announced a smaller
number.
The default version is 1.5.0 because the protobuf UDP format was introduced there, and a real client negotiates it on that basis.
The certificate
#![allow(unused)]
fn main() {
let identity = Identity::self_signed(vec!["localhost".to_owned()])?;
}
Self-signed generation exists for local development. A deployment supplies a persistent certificate instead. Clients key their local preferences on the certificate hash, so a fresh certificate at every restart looks like a new server each time.
A client certificate is requested but not required. When present it proves
identity rather than authority, and its lowercase SHA-1 hash reaches the router
in ConnectionIdentity. What that identity is worth is the application’s
decision.
TLS 1.2 is pinned. A widely deployed client build crashes on a TLS 1.3 session.
Limits that are not settable
Each of these has a cost behind it rather than a preference.
| limit | value | consequence of exceeding |
|---|---|---|
| scope depth | 4 segments | the render is refused |
| scopes observed per connection | 4 | the render is refused |
| context actions per connection | 64 | the render is refused |
| delta history retained | 256 versions | the connection is closed |
| output queue per connection | 1024 messages | the connection is closed |
| minimum interval between turns | 50 ms | turns are coalesced |
The two closures are worth distinguishing from the three refusals. A refused render keeps the previous view and closes nothing, because the fault is in the application’s own code and the committed view is still correct. A connection falling off the tail of the journal or overflowing its queue has no correct view left to keep, and closing it is the only alternative to letting it drift.
Running out of scopes is a modelling signal rather than a configuration problem. See Scope or overlay.
Data-plane limits
| limit | value |
|---|---|
| maximum voice datagram | 1024 bytes |
| minimum voice datagram | 2 bytes |
| sustained voice packets per connection | 200 per second |
| voice burst allowance | 400 packets |
| text messages per connection | 1 per second, burst of 5 |
A client at the usual 10 ms framing emits 100 packets per second, so the voice budget leaves headroom for a faster codec setting while bounding a flood to twice the legitimate worst case. A connection exceeding it has packets dropped rather than being disconnected.
The text allowance is the same one the reference server applies. A flood refused at the socket never crosses a mailbox nor wakes a shard.
The datagram bounds match the reference server exactly, which keeps tunnelled and direct audio on one rule.
Two entry points
serve covers the common case:
#![allow(unused)]
fn main() {
serve(config, identity, |runtime| {
let lobby = runtime.create_shard(|_handle| Lobby::new());
AlwaysAttach(lobby.shard())
})
.await
}
Gateway::bind followed by Gateway::serve splits the two, which is what a
test binding an ephemeral port needs in order to read the address back:
#![allow(unused)]
fn main() {
let gateway = Gateway::bind(config, identity).await?;
let address = gateway.address();
gateway.serve(router).await
}
Load testing
Two tools, measuring two different things. bench-shard measures the cost of
publishing a change, with no sockets involved. mumble-server-runtime-stress
measures a running server through real connections.
The cost of one turn
$ cargo run --release -p bench-shard
A turn here is the complete cold path of a state change: render the shard once, plan the shared delta once, journal it, republish the audio routing table, then for every connection filter that delta, splice its overlay, collapse, encode and push.
Draining the queues belongs to the connection tasks rather than the shard, so it happens outside the timed section. The figure measures the publication pipeline, not the socket.
Five connection counts are measured, 2 through 500, over twenty turns each. The reported figure is the median, so a run under unrelated system load still reports honestly.
conns median per connection worst delta ops
The last column is the one the exercise is about. It is the size of the shared delta, and it stays small while the connection count grows. A run where it tracks the population instead is the quadratic shape returning. See Cost.
The workload is a two-realm world with one member moved between realms per generation. The initial synchronisation is not timed.
ci/bench-shard.sh runs the same thing and deliberately applies no time
threshold. A continuous integration machine is not a stable bench. It fails only
when a turn fails, and prints the table so changes stay visible in the log.
Load against a running server
The stress tool opens real TLS and UDP connections and drives headless Mumble clients. Certificates are deliberately not verified.
Raise the admission ceiling first, since the demo’s default is conservative:
$ cargo run -p mumble-server-runtime-arena -- 127.0.0.1:64738 500
$ cargo run --release -p mumble-server-runtime-stress -- \
--clients 200 --duration 30s
Build it in release mode. A debug build measures the load generator.
| flag | default | effect |
|---|---|---|
--server | 127.0.0.1:64738 | TCP and UDP address |
-n, --clients | 1 | concurrent clients |
--scenario | connect | connect or arena |
--ramp | 0s | how far apart client starts are spread |
--duration | 30s | hold time after the ramp |
--ping-interval | 5s | TCP and UDP keepalive |
--username-prefix | stress | |
--password | none | opaque, reaches the router as a credential |
--failure-threshold | 0 | tolerated fraction of failed clients |
Durations take ms, s and m suffixes.
The exit code reflects the failure threshold, which is what makes the tool usable from a script rather than only by eye.
The two scenarios
connect establishes a connection, completes the handshake and holds it. It
measures admission and the steady state.
arena drives the demonstration application: pick a team in the lobby, migrate
through > Enter the Arena, switch base, return. It is state-driven rather than
timed, so each interaction is sent only once the client’s own Mumble model
proves the previous one arrived. What it exercises is the slow path, since every
one of those moves changes an observation.
Voice
Voice is opt-in and needs a clip of raw Opus packets of a fixed size:
$ cargo run --release -p mumble-server-runtime-stress -- \
--clients 200 \
--duration 30s \
--voice-file audio/clip.opuspack \
--talk-percent 5 \
--talk-spurt 2s
prepare-opus.sh under the tool’s directory builds such a clip. Generated media
is ignored by Git. Only material that may lawfully be downloaded and replayed
belongs there.
Frames are 10 ms, matching what a real client emits, and
--voice-frame-bytes states the fixed packet size the file is cut into.
What comes out
Distributions are reported as p50, p95, p99 and max for TCP connect, TLS handshake, Mumble synchronisation, and both ping round trips. Counters cover frames, packets, voice in both directions, interactions and denials.
denied is worth watching in the arena scenario. A denial there means the
application refused an interaction, which is either correct behaviour or a
scenario out of step with the logic, and telling the two apart is the point of
reporting it separately.
Mumble compatibility
Unmodified Mumble clients connect to this server, on every platform the Mumble project ships one for. No plugin, no custom build and no protocol extension is involved.
That comes with a shape a client can feel. Everything a conventional server lets a client change directly is refused here, because the voice session is rendered from application state rather than edited. This chapter states what a client gets as a result, and what backs the word compatible.
- What a client can do covers the features that work, the ones that are refused, and the messages behind both.
- The control plane follows a connection from the TLS handshake to a live session.
- The voice plane covers how speech travels, what is removed from it, and when a client falls back to the TCP tunnel.
- Establishing compatibility is the argument: what backs the claim, and what it does not cover.
Boundaries states the same limits as policy rather than as consequence. No types or code appear in this chapter, which describes wire behaviour rather than the interface an application programs against. That is Building an application.
What a client can do
An unmodified Mumble client connects, moves between the channels it can see, talks, types, and uses the menu entries an application offers it. It cannot create a channel, kick anyone, whisper, or display an avatar.
The line between the two is the one drawn in Boundaries. Anything that would change the voice session directly is refused, because the session is rendered. Anything that reports an intention is forwarded to the application, which decides.
What works
| the client does | result |
|---|---|
| connect | accepted up to the ceiling; name and password reach the application |
| enter a channel it can see | forwarded as a request, granted by the next render |
| talk | delivered to the receivers the application declared |
| mute or deafen itself | forwarded as a request, shown to others once rendered |
| type into a channel, or to one person | relayed if the application accepts it |
| use a menu entry offered to it | forwarded, along with what it was invoked on |
| ask what it may do in a channel | answered from the view it currently holds |
| ask for its own statistics | answered in full |
| ask about someone else | answered with a name and nothing more |
| measure latency | answered, with the counters its transport needs |
Entering a channel is the one worth reading twice. The client reports that it would like to move. Nothing has happened when the request arrives, and the move becomes real when the application renders it, or never. See Client interactions.
What is refused
| the client tries to | why |
|---|---|
| create, rename, move or remove a channel | the tree is rendered, never edited |
| kick, ban or move another participant | moderation belongs to the application |
| edit access control lists | there is no stored permission model |
| register a whisper or shout target | audio destinations come from the render |
| set an avatar, a comment or a texture | no blob is stored or served |
| register an account, or list registered users | nothing outlives the process |
| announce that it is recording | the recording flag is advisory only |
| renegotiate its encryption | resynchronisation is not implemented |
A refusal reaches the client as a denial it displays. Nothing is acknowledged silently: a change that will never appear in any view is refused rather than accepted and forgotten.
Two exceptions match the reference server. A message over the rate limit is dropped without an answer, because answering a flood is participating in it. An empty message is dropped because there is nothing to deliver.
What the client shows as a result
The permissions advertised to a client cover traversal, entry, speech and text. Channel and administration rights are deliberately withheld, so the corresponding menus do not appear at all rather than appearing and failing.
Avatars and comments stay empty, since the requests that would fetch them are refused. Whisper and shout keys produce nothing, since the target they would address was never registered.
Permissions are not a stored model. A question about a channel is answered from the render that produced it, and an entry is validated again when it arrives.
Text
A typed message is dropped if it arrives faster than one per second with a burst of five, if it is empty, if it is longer than the advertised limit, or if it carries markup on a server configured to refuse it. A server that forbids markup refuses it rather than rewriting it, because stripping markup correctly means running a parser on client input.
Those four are properties of the message. Whether the connection may address that channel or that person at all is a separate question, answered by the application. See Refusals.
The messages behind it
Every Mumble message type decodes, and an unknown type code or a malformed payload is an error rather than something silently accepted. What varies is what happens next.
| inbound | handling |
|---|---|
Authenticate | opens the session: name, credential, certificate hash |
UserState | two intents only: channel entry, self-mute and self-deafen |
TextMessage | relayed, subject to the four checks above |
ContextAction | forwarded to the application |
PermissionQuery, UserStats | answered from the published view |
Ping | answered with the timestamp and the decryption counters |
UdpTunnel | carried as voice |
Version | accepted and ignored, like any pre-authentication traffic |
| anything else | refused, and logged |
A name longer than 64 characters is truncated, and an absent one becomes
Guest. The password field is an opaque credential: nothing is inspected, and
what it is worth is decided when the connection is routed.
A UserState naming another session is refused, as is one carrying a recording
announcement, a plugin context, a listener registration or an access token.
| outbound | when |
|---|---|
Version | after the TLS handshake, before authentication |
CryptSetup, CodecVersion | before the first view |
ChannelState, ChannelRemove | a channel enters, changes or leaves the view |
UserState, UserRemove | a participant enters, changes or leaves the view |
ServerSync, ServerConfig | the view is complete, the session is usable |
Reject | the connection is refused, with a reason |
Ping, PermissionQuery, UserStats | in answer to a client, never unprompted |
TextMessage | a message the application relayed |
ContextActionModify | a menu entry is offered or withdrawn |
PermissionDenied | any refusal |
UdpTunnel | voice for a connection with no usable UDP path |
Nine types are never sent: Acl, BanList, QueryUsers, UserList,
VoiceTarget, SuggestConfig, PluginDataTransmission, RequestBlob and
ContextAction.
Pings, permission queries, statistics requests, codec announcements, blob requests and target registrations do not count as activity. A client sending nothing else is idle, the same accounting the reference server applies.
The control plane
Connecting a Mumble client looks ordinary from the client’s side: a TLS connection, a login, a channel tree, and a session that stays usable until the client leaves. This page covers what happens in that sequence, and the few places where the outcome depends on something the client cannot see.
The connection survives a migration between shards, so a client holds one for the whole of its stay.
From connect to a usable session
client server
TCP connect ─────────────────▶
◀───────────── TLS 1.2 handshake ─────────────▶
◀───────────── Version
Authenticate ────────────────▶
route the connection
◀───────────── CryptSetup, CodecVersion
◀───────────── the channel tree
◀───────────── ServerSync, ServerConfig
the session is live
The server announces itself as soon as TLS completes, before the login rather than in answer to it. Anything the client sends before logging in is accepted and ignored.
Routing happens between the login and the tree. A name, a password and a certificate fingerprint go in, and either a shard or a refusal comes out. It is the one moment where a connection and the claim behind it meet, and the only place an application decides who a connection is. See Anatomy of an application.
The tree the client receives
The channels and participants that arrive before synchronisation are not a login-specific sequence. They are the first ordinary transition of the shard the connection was routed to, sent through the same path as every later change.
For a reader that means the tree a client receives on connecting and the tree it holds an hour later are produced identically. There is no separate startup path that could agree with nothing else.
Two orderings inside it matter to the client. A parent channel arrives before its children, and a connection is introduced to itself before any other participant, because the client looks its own session up when synchronisation arrives.
A shard that publishes nothing within five seconds loses the connection, rather than leaving a client synchronised against an empty view.
Refusals
A connection is refused with a reason in two cases: the advertised user ceiling is reached, or the application declines to route it. The reason reaches the client and is displayed.
What the session advertises
Synchronisation carries the connection’s own session, the welcome text and the permissions it holds where nothing forbids them. The configuration that follows carries the bandwidth ceiling, the message length limit, the user ceiling, whether markup is accepted and whether recording is allowed.
Two of those are advertised rather than enforced. The bandwidth ceiling is sent and not applied. The recording flag is advisory, and a client announcing that it records is refused like any other unmirrored change.
Defaults are listed in Configuration and limits.
Latency, and one trap
A client ping is answered with its own timestamp and the connection’s decryption counters.
Those counters decide something the client never asks about directly. A Mumble client reads the count of accepted datagrams to judge whether its own UDP path works, and a count still at zero twenty seconds in makes it fall back to the TCP tunnel permanently, whether or not UDP was working in both directions.
Resynchronisation counters always report zero, which is their true value.
Transport
TLS 1.2 only. A widely deployed client build crashes in its post-handshake introspection when handed a TLS 1.3 session, and the reference server negotiates 1.2 by default.
A client certificate is requested and not required. When present, it is checked to parse and nothing more: a self-signed certificate proves identity, not authority. Its fingerprint reaches the application when the connection is routed, and what that identity is worth is the application’s decision.
A deployment supplies a persistent certificate. Clients key their per-server preferences on it, so a certificate regenerated at every restart looks like a new server each time, and every client loses its local settings. Generation exists for local development, where that does not matter.
The voice plane
A client speaks, and the people the application put within earshot hear it. This page covers what happens between those two facts, and the handful of cases where a client behaves differently from what its own interface suggests.
Opus, carried and not opened
The audio a client sends is Opus, and it is forwarded exactly as it arrived. Nothing decodes it, so there is no mixing, no transcoding and no server-side analysis of what anyone said.
The codec announcement sent before the first view leaves Opus alone and disables the legacy codecs, which is the reference server’s own reset state.
Two ways to the server, chosen by the client
Voice normally travels over UDP. A client whose UDP path does not work sends its voice through the control connection instead, and receives it the same way.
Neither side negotiates the switch. A client that tunnels its voice is stating that UDP failed, and is served through the tunnel from then on. Any datagram arriving over UDP puts it back on the fast path.
The choice is per connection, so a client on UDP and a client on the tunnel hear each other with nothing special happening. The sender’s transport never enters into how a receiver is served.
A client that has never sent a datagram has no UDP address on the server side. Its address is learned from a datagram that decrypts under its own key, which makes association a proof rather than a claim.
Who hears whom
The application declares the audio relation, and the runtime applies it. See Audio as a relation.
One rule sits underneath and is not the application’s to set: a receiver is given a speaker’s audio only once it has been told the speaker exists. A Mumble client discards audio from a session it does not know, so delivering it earlier would be delivering silence.
The check errs on the side of silence. A client lagging behind loses audio from speakers it already knew about, at worst a fraction of a second. Hearing someone absent from the view is never possible. See Seeing the speaker.
Withdrawing audibility needs no such care. A route that is gone is simply absent, and cutting early is always safe.
Where speech goes
Two destinations are accepted: normal speech, and the loopback a client uses to test its own microphone. Loopback is answered directly to the speaker and is never a route to anyone else.
Every other destination is dropped. Registering a whisper or shout target is refused, so a target the server never granted means nothing, and treating it as normal speech would deliver voice to people the client never addressed.
What is removed on the way
Positional data is stripped from every forwarded packet. Passing on coordinates the application never authorised would tell everyone in earshot where the speaker is. Position may still reach a client through Mumble Link, which is local to each participant and never reaches the server.
The volume adjustment a sender attaches is cleared. How loud someone is heard is not the speaker’s decision.
Before connecting
A connectivity ping is answered before any session exists, reporting the version, the current and maximum user counts and the bandwidth ceiling. This is how a client measures a server before connecting, and how a server list probes one.
Limits
Voice limits are fixed and per connection: a maximum packet size, a sustained packet rate with a burst allowance, and a bandwidth window billed with the same per-packet overhead the reference server uses.
A connection exceeding them has packets dropped rather than being disconnected, and both paths into the server share one rule, so tunnelling voice is not a way around it.
Voice pushed onto a saturated queue is dropped. A gap is preferable to voice arriving late. A datagram that fails to decrypt is dropped and counted; there is no resynchronisation.
Establishing compatibility
“Compatible with Mumble” is worth exactly what backs it. This runtime shares no code with the Mumble project, so nothing about it is compatible by construction: every message it sends is one it decided to send, and the only proof that a real client accepts it is that a real client was asked.
An implementation also cannot certify itself. A test written from the same understanding that produced a defect agrees with the defect. What follows is what backs the claim here, and the last section states what it does not cover.
The protocol is not guessed
Protocol facts come from the Mumble project’s own sources, pinned at a single commit and kept in the repository next to the extracts they were taken from. The message schema is compiled from those files when the workspace builds, so the wire format is never transcribed by hand and cannot drift from its source through a typing mistake.
Every module asserting a protocol fact names the upstream file it came from. Those references are a convention rather than a machine check, and nothing verifies that one is still accurate. It is the weakest link in what follows.
The encryption layer is the one place upstream truth is executed rather than cited: it is checked against the published test vectors for the cipher it implements.
Real traffic, recorded
Seven sessions between an official Mumble client and a real reference server were captured and kept: a login, channel entry and exit, channel creation and removal, two people talking, a whisper, a refusal, a disconnection.
Replaying them proves three things at once. Every byte of every stream is accounted for, so nothing is quietly skipped as unrecognised. Every decoded message re-encodes to the bytes that were originally captured, so the codec reads real traffic without losing anything. And a capture from an older server, whose voice format is deliberately out of scope, is still required to be rejected rather than misparsed into something plausible.
The corpus grows slowly, because capturing a scenario means driving a graphical client by hand.
Real traffic, live
A proxy sits between a real client and a real reference server, decoding and re-encoding every message in flight and rewriting the key exchange so it holds an independent cipher domain toward each side.
The codec therefore meets live traffic generated by two implementations that know nothing about it. A message that survives the round trip unchanged has been read correctly, and neither endpoint had any way to be lenient about it.
A client that refuses to be lied to
A simulated Mumble client applies every message it receives to a strict model of what a client believes, and rejects any state the official client would reject. It is written against the specification and the upstream sources, never against this server, which is what makes its verdict worth anything.
It refuses a channel whose parent it cannot see, a participant placed in a channel it cannot see, a cycle in the tree, the removal of an occupied channel or of a parent before its children, the removal of the root, any message naming a channel or a session invisible to it, an identifier reused after being retired, and synchronisation arriving before the connection has been introduced to itself.
Each of those is a separate named check rather than one large validation pass, so deleting one is visible instead of being absorbed by another. A table-driven test feeds an invisible identifier into every field of every message that carries one, and requires each to be caught.
Catching the mistake that repairs itself
That same strict client is the right-hand side of an equality checked after every message a shard sends:
what the client believes == the scoped shared view + its private overlay
Two decisions inside that sentence carry the weight.
The model is fed encoded messages rather than the runtime’s internal plan, so the translation between the two is inside the property instead of being assumed correct.
The check runs after every message rather than at the end of a change. This is the point of the whole exercise: an ordering defect produces a state that is briefly wrong and finally right, so comparing only the endpoints sees nothing. A client that flickers, or that momentarily shows a channel it should never have seen, fails here and nowhere else.
The generator interleaves four kinds of change at random, and asserts its own coverage, because a generator that quietly stops exercising one of them is worse than none. Three further tests compose the pipeline wrongly on purpose and require the property to fail; if one ever passes, the generator has gone weak rather than the code correct.
Real sockets, and arbitrary bytes
The end-to-end suite opens real TLS connections and real datagrams against a server on a loopback port. Nothing is mocked and nothing sleeps: a test advances when the server has actually said something. It covers what a client experiences rather than what a function returns, including two clients hearing each other over UDP, a client served through the tunnel because it never opened UDP, a one-way listener, two connections given different trees, and a migration that does not remove a client from its own view.
Underneath, the decoders are required to return on arbitrary bytes, never to panic, and to fail closed rather than accept something they did not understand. Re-encoding a decoded message is required to reproduce the original bytes exactly, which is the property the live proxy depends on.
The judge is not the defendant
A change may not touch a judge and the implementation it judges in the same revision. Loosening a model, editing a captured session or weakening a check is legitimate only as its own reviewed change, never as a side effect of making something else pass.
What none of this proves
The list matters as much as the mechanisms.
Rules a client cannot observe are out of reach. A model built from what arrives on the wire judges only what arrives on the wire.
The strict model knows structure and identity: channels, participants, parents, names, sessions. It does not model permissions, positions, flags, comments or links, so a defect confined to those fields passes it.
Three deliberately wrong pipelines are three known kinds of defect, not a measure of coverage. They show the property is sensitive to those three and say nothing about the rest.
The corpus contains no encryption resynchronisation and no older client, which are the two cases most likely to behave unexpectedly.
Official Mumble clients were used throughout development, and that is a person watching rather than a machine checking. It catches what a person notices.
Running them
# everything below, and the rest
cargo test --workspace
cargo test -p mumble-server-runtime-corpus-decode # the corpus
cargo test -p mumble-server-runtime-mitm-proxy # the proxy, offline
cargo test -p mumble-server-runtime-testkit # the simulated client
cargo test -p mumble-server-runtime-shard --test oracle # the equality above
cargo test -p mumble-server-runtime-gateway --test gateway # real sockets
ci/fuzz-smoke.sh # every fuzz target
The proxy is also a program. Pointing a real client at it, with a reference server upstream, is the observation the offline tests stand in for:
cargo run -p mumble-server-runtime-mitm-proxy -- \
--listen 0.0.0.0:64738 --upstream 127.0.0.1:64739 --tls-name localhost
API documentation
The generated API documentation is published alongside this book, under
/api/. It is produced by cargo doc from the doc
comments in the workspace, and covers every public item of every crate.
Entry points, in the order a reader is likely to need them:
| crate | contents |
|---|---|
mumble_server_runtime_shard | shared views, scopes, overlays, audio relations, deltas, the shard loop |
mumble_server_runtime_gateway | TLS control plane, handshake, connection routing, the voice plane |
mumble_server_runtime_protocol | TCP framing, control messages, UDP envelopes |
mumble_server_runtime_crypto | OCB2-AES128 and the per-connection crypto state |
A broken intra-doc link fails the build, so every link in the generated documentation resolves.
To build the same documentation locally:
$ cargo doc --workspace --no-deps --all-features --open