bevy_iroh
Share Bevy entities between peers over iroh: QUIC dialled by public
key, hole punching with relay fallback, gossip for the room, direct streams for one peer. No
server. Tag an entity Shared and everyone in the room has it.
use *;
use *;
use ;
;
// Runs for my cube and for every cube a peer sends: one code path for visuals.
Run cargo run --example cube, copy the ticket it prints, and run it again with the ticket on
another machine. Each sees the other's cube glide as its owner drives it with the arrow keys.
What is in it
IrohPlugin |
one tokio thread owning the endpoint; Identity::{Ephemeral, File, Key}; Relays::{N0, Extend, Only, Disabled}; with_protocol(alpn, handler) for your own ALPNs on the same endpoint |
Room |
an entity, not a place: Room::host(name) / Room::join(ticket), then RoomStatus, Ticket, RoomTopic on it. Despawn it to leave |
Peer |
one entity per remote member per room, MemberOf(room); PeerJoined / PeerLeft messages; heartbeats every 5 s, reaped after three silent ones |
Shared |
replicate this entity into every room I am in, or the one it is InRoom. NetId is its identity across peers (NetId::keyed("floor") for one that everyone spawns), Owner who made it, Remote on replicas |
replicate::<T>() |
derive serde and T crosses. replicate_with(codec) for anything else: a quantised form, validation, a component from another crate, a smoothing target on arrival (Codec has Source, Wire, Target) |
Transform |
20 Hz, replicas ease toward each arrival over an interval learned from real gaps; never extrapolates |
add_net_message::<T>() |
typed messages: NetSender::broadcast is gossip to the room, send_to dials one peer; read Received<T> with via saying which |
Iroh |
the resource: id(), endpoint(), spawn(future) on the network runtime |
On the wire every message is signed by its author, and the body is opaque and length-prefixed
under a hashed kind, so a peer on an older build steps over what it does not know and keeps
working. Received data is treated as hostile: sizes are capped, unknown kinds are logged, and a
codec's verify runs before its decode.
Voice and video: the media feature
= { = "0.3", = ["ui"] } # ui = media + ready-made Bevy UI
Put Voice on a shared entity and your microphone goes to everyone in the room. A remote
entity that arrives with Voice is played back from where it is, relative to the camera that
carries AudioListener. VoiceLevel on both is a meter; Voice::muted is replicated so peers
can show it. Frames are 20 ms opus over QUIC datagrams on the same endpoint, with a jitter
buffer per track and concealment for what gossip would have retransmitted too late.
Video is the same shape: VideoFeed on a shared entity, paired locally with a VideoInput.
VideoInput::camera() is the camera named in the settings; VideoInput::new(source) is any
VideoSource of your own (the crate ships a TestPattern). H.264 goes out as one QUIC stream
per group of pictures, each subscriber fed by a task of its own, so a peer on a slow link
skips to the next keyframe and nobody else notices. A VideoFeed gets a VideoImage handle
once a frame is there, remote ones from the decoder and your own as a preview; put it on a
material.
Which devices: MediaSettings names the microphone, speaker and camera by id, and
AudioDevices / CameraDevices are the lists to pick from. Change a setting and the device
is reopened while everything published keeps its track ids. MicLevel is the microphone's
level for a meter in a corner.
The ui feature is the corner and the meters, built: commands.spawn(MediaPanel::voice())
is a mute button, a level bar and a "Devices" button that opens the pickers; VoiceIndicator
on any entity with a VoiceLevel floats a small green bar over it. So a room with voice is
an entity with Voice and one line of UI:
Or with just: just voice, just webcam <ticket>, just web webcam,
and just check for everything a commit needs. just alone lists them.
The buffering is the part that decides whether a call is usable, and it follows what
substrate learned on real calls: capture and playback buffers of 512 frames (11 ms) where the
device allows; a 60 ms jitter target that doubles on an underrun (after a one-second grace)
and halves again after ten clean seconds, capped at 240 ms; an underrun is a whole buffer of
silence rather than a splice; backlog is spent by playing up to 2% fast rather than kept; a
lost frame is rebuilt from the next frame's in-band FEC; muted sends silence so a muted peer
is quiet, not gone. Video that falls more than 150 ms behind a waiting keyframe skips to it.
VoiceStats and VideoStats on each remote track carry the counters.
On a desktop this is cpal, libopus and openh264 (the last two built from source with cmake);
headphones, since there is no echo cancellation. In a browser it is Web Audio and WebCodecs,
which do have echo cancellation, and the two decode each other: opus is opus and the H.264 is
Annex B both ways. Cameras on a desktop are the v4l2 feature (Linux, through bevy_v4l2;
an app that already runs a bevy_v4l2 Webcam entity can share its capture with
VideoInput::new(feed.capture.color_tap())); elsewhere give the entity a VideoSource.
Browsers: the wasm feature
The core transport runs in a page as it is: iroh reaches the relays over WebSockets. The
wasm feature adds an identity kept in localStorage (web::stored_identity) and the ticket
out of the address bar (web::ticket_from_url("join")). scripts/web.sh [example] builds an
example for the browser and serves it; open http://localhost:8000/?join=<ticket> from a
native voice, webcam or host run. Microphone and camera need https or localhost, and a
browser lists them only after its permission prompt: "Devices" asks. WebCodecs is behind
web-sys's unstable cfg, which .cargo/config.toml sets for the wasm target; copy that into
your own project. tests/wasm.rs runs two apps in one page against the real relay under
wasm-bindgen-test (cargo test --profile wasm-test --target wasm32-unknown-unknown --features wasm --test wasm with geckodriver or chromedriver on PATH).
Status
Rooms, presence, replication, messages, voice and video work, each with a two-app integration
test over real iroh (cargo test --features media); a browser peer joins a native host and
hears and sees it. Next: substrate moving onto this crate. See PLAN.md.
License
MIT or Apache-2.0, at your option.