├── bevy_matchbox ├── README.md ├── src │ ├── lib.rs │ ├── socket.rs │ └── signaling.rs ├── examples │ ├── hello.rs │ ├── hello_signaling.rs │ └── hello_host.rs └── Cargo.toml ├── matchbox_server ├── .gitignore ├── src │ ├── args.rs │ ├── main.rs │ └── state.rs ├── README.md ├── Dockerfile └── Cargo.toml ├── matchbox_socket ├── README.md ├── .gitignore ├── src │ ├── lib.rs │ ├── webrtc_socket │ │ ├── messages.rs │ │ ├── signal_peer.rs │ │ └── error.rs │ ├── error.rs │ └── ggrs_socket.rs └── Cargo.toml ├── matchbox_signaling ├── README.md ├── src │ ├── lib.rs │ ├── signaling_server │ │ ├── mod.rs │ │ ├── error.rs │ │ ├── callbacks.rs │ │ ├── server.rs │ │ ├── handlers.rs │ │ └── builder.rs │ ├── error.rs │ └── topologies │ │ ├── mod.rs │ │ ├── full_mesh.rs │ │ └── client_server.rs ├── examples │ ├── full_mesh.rs │ └── client_server.rs ├── Cargo.toml └── tests │ ├── full_mesh.rs │ └── client_server.rs ├── .github ├── linters │ └── .markdownlint.yml ├── dependabot.yml └── workflows │ ├── markdown-lint.yml │ ├── lint-toml.yml │ ├── website.yml │ └── code.yml ├── examples ├── simple │ ├── .cargo │ │ └── config.toml │ ├── Cargo.toml │ ├── README.md │ └── src │ │ └── main.rs ├── async_example │ ├── .cargo │ │ └── config.toml │ ├── Cargo.toml │ └── src │ │ └── main.rs ├── error_handling │ ├── .cargo │ │ └── config.toml │ ├── Cargo.toml │ ├── README.md │ └── src │ │ └── main.rs ├── custom_signaller │ ├── .cargo │ │ └── config.toml │ ├── src │ │ ├── lib.rs │ │ ├── get_browser_url.rs │ │ ├── direct_message.rs │ │ └── main.rs │ ├── Cargo.toml │ └── README.md └── bevy_ggrs │ ├── .gitignore │ ├── assets │ └── fonts │ │ ├── quicksand-light.ttf │ │ └── quicksand-light.txt │ ├── .cargo │ └── config.toml │ ├── Cargo.toml │ ├── src │ ├── args.rs │ ├── main.rs │ └── box_game.rs │ └── README.md ├── images ├── matchbox_logo.png └── connection.excalidraw.svg ├── rustfmt.toml ├── .markdownlint.yml ├── .gitignore ├── Cargo.toml ├── LICENSE-MIT ├── matchbox_protocol ├── Cargo.toml └── src │ └── lib.rs ├── README.md └── LICENSE-APACHE /bevy_matchbox/README.md: -------------------------------------------------------------------------------- 1 | ../README.md -------------------------------------------------------------------------------- /matchbox_server/.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | -------------------------------------------------------------------------------- /matchbox_socket/README.md: -------------------------------------------------------------------------------- 1 | ../README.md -------------------------------------------------------------------------------- /matchbox_signaling/README.md: -------------------------------------------------------------------------------- 1 | ../README.md -------------------------------------------------------------------------------- /matchbox_socket/.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | Cargo.lock 3 | -------------------------------------------------------------------------------- /.github/linters/.markdownlint.yml: -------------------------------------------------------------------------------- 1 | ../../.markdownlint.yml -------------------------------------------------------------------------------- /examples/simple/.cargo/config.toml: -------------------------------------------------------------------------------- 1 | [target.wasm32-unknown-unknown] 2 | runner = "wasm-server-runner" 3 | -------------------------------------------------------------------------------- /images/matchbox_logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/johanhelsing/matchbox/HEAD/images/matchbox_logo.png -------------------------------------------------------------------------------- /examples/async_example/.cargo/config.toml: -------------------------------------------------------------------------------- 1 | [target.wasm32-unknown-unknown] 2 | runner = "wasm-server-runner" 3 | -------------------------------------------------------------------------------- /examples/error_handling/.cargo/config.toml: -------------------------------------------------------------------------------- 1 | [target.wasm32-unknown-unknown] 2 | runner = "wasm-server-runner" 3 | -------------------------------------------------------------------------------- /examples/custom_signaller/.cargo/config.toml: -------------------------------------------------------------------------------- 1 | [target.wasm32-unknown-unknown] 2 | runner = "wasm-server-runner" 3 | -------------------------------------------------------------------------------- /rustfmt.toml: -------------------------------------------------------------------------------- 1 | imports_granularity = "Crate" 2 | max_width = 100 3 | comment_width = 100 4 | wrap_comments = true 5 | -------------------------------------------------------------------------------- /.markdownlint.yml: -------------------------------------------------------------------------------- 1 | { 2 | "MD026": false, # Trailing punctuation in heading 3 | "MD013": false, # Line length 4 | } -------------------------------------------------------------------------------- /examples/bevy_ggrs/.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | crates/*/target 3 | **/*.rs.bk 4 | .cargo/config 5 | /.idea 6 | /.vscode 7 | /benches/target 8 | /dist 9 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | crates/*/target 3 | **/*.rs.bk 4 | .cargo/config 5 | /.idea 6 | /.vscode 7 | /benches/target 8 | *.code-workspace 9 | .DS_Store -------------------------------------------------------------------------------- /examples/bevy_ggrs/assets/fonts/quicksand-light.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/johanhelsing/matchbox/HEAD/examples/bevy_ggrs/assets/fonts/quicksand-light.ttf -------------------------------------------------------------------------------- /examples/bevy_ggrs/.cargo/config.toml: -------------------------------------------------------------------------------- 1 | [target.wasm32-unknown-unknown] 2 | runner = "wasm-server-runner" 3 | rustflags = ['--cfg', 'getrandom_backend="wasm_js"'] 4 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [workspace] 2 | members = [ 3 | "bevy_matchbox", 4 | "matchbox_protocol", 5 | "matchbox_socket", 6 | "matchbox_server", 7 | "matchbox_signaling", 8 | "examples/*", 9 | ] 10 | resolver = "2" # Important! Bevy/WGPU needs this! 11 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: github-actions 4 | directory: / 5 | schedule: 6 | interval: weekly 7 | - package-ecosystem: docker 8 | directory: matchbox_server/ 9 | schedule: 10 | interval: weekly 11 | -------------------------------------------------------------------------------- /matchbox_server/src/args.rs: -------------------------------------------------------------------------------- 1 | use clap::Parser; 2 | use std::net::SocketAddr; 3 | 4 | #[derive(Parser, Debug)] 5 | #[clap( 6 | name = "made_in_heaven", 7 | rename_all = "kebab-case", 8 | rename_all_env = "screaming-snake" 9 | )] 10 | pub struct Args { 11 | #[clap(default_value = "0.0.0.0:3536", env)] 12 | pub host: SocketAddr, 13 | } 14 | -------------------------------------------------------------------------------- /examples/custom_signaller/src/lib.rs: -------------------------------------------------------------------------------- 1 | pub(crate) mod direct_message; 2 | mod iroh_gossip_signaller; 3 | pub use iroh_gossip_signaller::IrohGossipSignallerBuilder; 4 | 5 | #[cfg(target_arch = "wasm32")] 6 | pub mod get_browser_url; 7 | 8 | pub fn get_timestamp() -> u128 { 9 | web_time::SystemTime::now() 10 | .duration_since(web_time::UNIX_EPOCH) 11 | .unwrap() 12 | .as_micros() 13 | } 14 | -------------------------------------------------------------------------------- /examples/custom_signaller/src/get_browser_url.rs: -------------------------------------------------------------------------------- 1 | pub fn get_browser_url_hash() -> Option { 2 | // Get window.location object 3 | let window = web_sys::window().expect("no global `window` exists"); 4 | let location = window.location(); 5 | 6 | // Get the hash part of the URL (including the # symbol) 7 | let hash = location.hash().ok()?; 8 | 9 | // Remove the leading # symbol and return the remaining string 10 | Some(hash.trim_start_matches('#').to_string()) 11 | } 12 | -------------------------------------------------------------------------------- /matchbox_socket/src/lib.rs: -------------------------------------------------------------------------------- 1 | #![warn(missing_docs)] 2 | #![doc = include_str!("../README.md")] 3 | #![forbid(unsafe_code)] 4 | 5 | mod error; 6 | #[cfg(feature = "ggrs")] 7 | mod ggrs_socket; 8 | mod webrtc_socket; 9 | 10 | pub use async_trait; 11 | pub use error::{Error, SignalingError}; 12 | pub use matchbox_protocol::PeerId; 13 | pub use webrtc_socket::{ 14 | ChannelConfig, MessageLoopFuture, Packet, PeerEvent, PeerRequest, PeerSignal, PeerState, 15 | RtcIceServerConfig, Signaller, SignallerBuilder, WebRtcChannel, WebRtcSocket, 16 | WebRtcSocketBuilder, error::ChannelError, 17 | }; 18 | -------------------------------------------------------------------------------- /matchbox_server/README.md: -------------------------------------------------------------------------------- 1 | # Signaling Server 2 | 3 | A lightweight signaling server with some opinions, to work with all [examples](../examples/). 4 | 5 | ## Next rooms 6 | 7 | This signaling server supports a rudimentary form of matchmaking. By appending `?next=3` to the room id, the next three players to join will be connected, and then the next three players will be connected separately to the first three. 8 | 9 | You can also use the room id for scoping what kind of players you want to match. i.e.: `wss://match.example.com/awesome_game_v1.1.0_pvp?next=2` 10 | 11 | ## Run 12 | 13 | ```sh 14 | cargo run 15 | ``` 16 | -------------------------------------------------------------------------------- /matchbox_socket/src/webrtc_socket/messages.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | 3 | /// Events go from signaling server to peer 4 | pub type PeerEvent = matchbox_protocol::PeerEvent; 5 | 6 | /// Requests go from peer to signaling server 7 | pub type PeerRequest = matchbox_protocol::PeerRequest; 8 | 9 | /// Signals go from peer to peer via the signaling server 10 | #[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone)] 11 | pub enum PeerSignal { 12 | /// Ice Candidate 13 | IceCandidate(String), 14 | /// Offer 15 | Offer(String), 16 | /// Answer 17 | Answer(String), 18 | } 19 | -------------------------------------------------------------------------------- /matchbox_signaling/src/lib.rs: -------------------------------------------------------------------------------- 1 | #![warn(missing_docs)] 2 | #![doc = include_str!("../README.md")] 3 | #![forbid(unsafe_code)] 4 | mod error; 5 | mod signaling_server; 6 | /// Network topologies to be created by the [`SignalingServer`] 7 | pub mod topologies; 8 | 9 | pub use error::Error; 10 | pub use signaling_server::{ 11 | NoCallbacks, NoState, SignalingCallbacks, SignalingState, 12 | builder::SignalingServerBuilder, 13 | callbacks::Callback, 14 | error::{ClientRequestError, SignalingError}, 15 | handlers::WsStateMeta, 16 | server::SignalingServer, 17 | }; 18 | pub use topologies::{SignalingTopology, common_logic}; 19 | -------------------------------------------------------------------------------- /matchbox_socket/src/error.rs: -------------------------------------------------------------------------------- 1 | //! Error definitions for socket operations. 2 | 3 | pub use crate::webrtc_socket::error::SignalingError; 4 | 5 | /// Errors that can happen when using Matchbox sockets. 6 | #[derive(Debug, thiserror::Error)] 7 | pub enum Error { 8 | /// An error occurring if the connection fails to establish. Perhaps check your connection or 9 | /// try again. 10 | #[error("The connection failed to establish. Check your connection and try again.")] 11 | ConnectionFailed(SignalingError), 12 | /// Disconnected from the signaling server 13 | #[error("The signaling server connection was severed.")] 14 | Disconnected(SignalingError), 15 | } 16 | -------------------------------------------------------------------------------- /.github/workflows/markdown-lint.yml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | branches: 4 | - main 5 | pull_request: 6 | 7 | name: Markdown Lint 8 | 9 | jobs: 10 | markdownlint: 11 | runs-on: ubuntu-latest 12 | timeout-minutes: 30 13 | steps: 14 | - uses: actions/checkout@v6 15 | with: 16 | # full git history is needed to get a proper list of changed files within `super-linter` 17 | fetch-depth: 0 18 | - name: Run Markdown Lint 19 | uses: docker://ghcr.io/github/super-linter:slim-v4 20 | env: 21 | MULTI_STATUS: false 22 | VALIDATE_ALL_CODEBASE: false 23 | VALIDATE_MARKDOWN: true 24 | DEFAULT_BRANCH: main 25 | -------------------------------------------------------------------------------- /matchbox_signaling/src/signaling_server/mod.rs: -------------------------------------------------------------------------------- 1 | pub(crate) mod builder; 2 | pub(crate) mod callbacks; 3 | pub(crate) mod error; 4 | pub(crate) mod handlers; 5 | pub(crate) mod server; 6 | 7 | /// State managed by the signaling server 8 | pub trait SignalingState: Clone + Send + Sync + 'static {} 9 | 10 | /// Callbacks used by the signaling server 11 | pub trait SignalingCallbacks: Default + Clone + Send + Sync + 'static {} 12 | 13 | /// Store no signaling callbacks 14 | #[derive(Default, Debug, Copy, Clone)] 15 | pub struct NoCallbacks {} 16 | impl SignalingCallbacks for NoCallbacks {} 17 | 18 | /// Store no state 19 | #[derive(Clone)] 20 | pub struct NoState {} 21 | impl SignalingState for NoState {} 22 | -------------------------------------------------------------------------------- /matchbox_socket/src/webrtc_socket/signal_peer.rs: -------------------------------------------------------------------------------- 1 | use crate::webrtc_socket::{PeerId, PeerRequest, PeerSignal}; 2 | use futures_channel::mpsc::UnboundedSender; 3 | 4 | #[derive(Debug, Clone)] 5 | pub struct SignalPeer { 6 | pub id: PeerId, 7 | pub sender: UnboundedSender, 8 | } 9 | 10 | impl SignalPeer { 11 | pub fn send(&self, signal: PeerSignal) { 12 | let req = PeerRequest::Signal { 13 | receiver: self.id, 14 | data: signal, 15 | }; 16 | self.sender.unbounded_send(req).expect("Send error"); 17 | } 18 | 19 | pub fn new(id: PeerId, sender: UnboundedSender) -> Self { 20 | Self { id, sender } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /matchbox_signaling/src/error.rs: -------------------------------------------------------------------------------- 1 | use crate::signaling_server::error::SignalingError; 2 | 3 | /// Errors that can occur in the lifetime of a signaling server. 4 | #[derive(Debug, thiserror::Error)] 5 | pub enum Error { 6 | /// An error occurring during the signaling loop. 7 | #[error("An unrecoverable error in the signaling loop: {0}")] 8 | Signaling(#[from] SignalingError), 9 | 10 | /// An error occurring from hyper 11 | #[error("Hyper error: {0}")] 12 | Hyper(#[from] hyper::Error), 13 | 14 | /// Couldn't bind to socket 15 | #[error("Bind error: {0}")] 16 | Bind(std::io::Error), 17 | 18 | /// Error on serve 19 | #[error("Serve error: {0}")] 20 | Serve(std::io::Error), 21 | } 22 | -------------------------------------------------------------------------------- /examples/simple/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "simple_example" 3 | version = "0.1.0" 4 | edition = "2024" 5 | publish = false 6 | 7 | [dependencies] 8 | matchbox_socket = { path = "../../matchbox_socket" } 9 | futures-timer = { version = "3", features = ["wasm-bindgen"] } 10 | log = { version = "0.4", default-features = false } 11 | 12 | [target.'cfg(target_arch = "wasm32")'.dependencies] 13 | console_error_panic_hook = "0.1.7" 14 | console_log = "1.0" 15 | futures = { version = "0.3", default-features = false } 16 | wasm-bindgen-futures = "0.4.29" 17 | 18 | [target.'cfg(not(target_arch = "wasm32"))'.dependencies] 19 | futures = "0.3" 20 | tokio = "1.32" 21 | tracing-subscriber = { version = "0.3", features = ["env-filter"] } 22 | -------------------------------------------------------------------------------- /examples/error_handling/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "error_handling_example" 3 | version = "0.1.0" 4 | edition = "2024" 5 | publish = false 6 | 7 | [dependencies] 8 | matchbox_socket = { path = "../../matchbox_socket" } 9 | futures-timer = { version = "3", features = ["wasm-bindgen"] } 10 | log = { version = "0.4", default-features = false } 11 | 12 | [target.'cfg(target_arch = "wasm32")'.dependencies] 13 | console_error_panic_hook = "0.1.7" 14 | console_log = "1.0" 15 | futures = { version = "0.3", default-features = false } 16 | wasm-bindgen-futures = "0.4.29" 17 | 18 | [target.'cfg(not(target_arch = "wasm32"))'.dependencies] 19 | futures = "0.3" 20 | tokio = "1.32" 21 | tracing-subscriber = { version = "0.3", features = ["env-filter"] } 22 | -------------------------------------------------------------------------------- /matchbox_server/Dockerfile: -------------------------------------------------------------------------------- 1 | # Signaling server as a docker image 2 | # 3 | # to build, run `docker build -f matchbox_server/Dockerfile` from root of the 4 | # repository 5 | 6 | FROM rust:1.92-slim-bullseye AS builder 7 | 8 | WORKDIR /usr/src/matchbox_server/ 9 | 10 | COPY README.md /usr/src/README.md 11 | COPY matchbox_server/Cargo.toml /usr/src/matchbox_server/Cargo.toml 12 | COPY matchbox_protocol /usr/src/matchbox_protocol 13 | COPY matchbox_server /usr/src/matchbox_server 14 | COPY matchbox_signaling /usr/src/matchbox_signaling 15 | 16 | RUN cargo build --release 17 | 18 | FROM debian:bullseye-slim 19 | RUN apt-get update && apt-get install -y libssl1.1 && rm -rf /var/lib/apt/lists/* 20 | COPY --from=builder /usr/src/matchbox_server/target/release/matchbox_server /usr/local/bin/matchbox_server 21 | CMD ["matchbox_server"] 22 | -------------------------------------------------------------------------------- /.github/workflows/lint-toml.yml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | branches: 4 | - main 5 | pull_request: 6 | 7 | name: Lint TOML 8 | 9 | jobs: 10 | lint: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Checkout sources 14 | uses: actions/checkout@v6 15 | 16 | - name: Install stable toolchain 17 | uses: actions-rs/toolchain@v1 18 | with: 19 | profile: minimal 20 | toolchain: stable 21 | override: true 22 | 23 | - name: Rust Cache 24 | uses: Swatinem/rust-cache@v2.7.3 25 | 26 | - name: Install Taplo 27 | uses: actions-rs/cargo@v1 28 | with: 29 | command: install 30 | args: taplo-cli --locked 31 | 32 | - name: Lint 33 | run: | 34 | taplo check --default-schema-catalogs 35 | taplo fmt --check --diff 36 | -------------------------------------------------------------------------------- /bevy_matchbox/src/lib.rs: -------------------------------------------------------------------------------- 1 | #![warn(missing_docs)] 2 | #![doc = include_str!("../README.md")] 3 | #![forbid(unsafe_code)] 4 | 5 | use cfg_if::cfg_if; 6 | 7 | mod socket; 8 | pub use socket::*; 9 | 10 | cfg_if! { 11 | if #[cfg(all(not(target_arch = "wasm32"), feature = "signaling"))] { 12 | mod signaling; 13 | pub use signaling::*; 14 | } 15 | } 16 | 17 | /// use `bevy_matchbox::prelude::*;` to import common resources and commands 18 | pub mod prelude { 19 | pub use crate::{CloseSocketExt, MatchboxSocket, OpenSocketExt}; 20 | use cfg_if::cfg_if; 21 | pub use matchbox_socket::{ChannelConfig, PeerId, PeerState, WebRtcSocketBuilder}; 22 | 23 | cfg_if! { 24 | if #[cfg(all(not(target_arch = "wasm32"), feature = "signaling"))] { 25 | pub use crate::signaling::{MatchboxServer, StartServerExt, StopServerExt}; 26 | pub use matchbox_signaling::SignalingServerBuilder; 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /examples/simple/README.md: -------------------------------------------------------------------------------- 1 | # Simple Example 2 | 3 | Shows how to use `matchbox_socket` in a simple example. 4 | 5 | ## Instructions 6 | 7 | - Run the matchbox-provided [`matchbox_server`](../../matchbox_server/) ([help](../../matchbox_server/README.md)), or run your own on `ws://localhost:3536/`. 8 | - Run the demo 9 | - [on Native](#run-on-native) 10 | - [on WASM](#run-on-wasm) 11 | 12 | ## Run on Native 13 | 14 | ```sh 15 | cargo run 16 | ``` 17 | 18 | ## Run on WASM 19 | 20 | ### Prerequisites 21 | 22 | Install the `wasm32-unknown-unknown` target 23 | 24 | ```sh 25 | rustup target install wasm32-unknown-unknown 26 | ``` 27 | 28 | Install a lightweight web server 29 | 30 | ```sh 31 | cargo install wasm-server-runner 32 | ``` 33 | 34 | ### Serve 35 | 36 | ```sh 37 | cargo run --target wasm32-unknown-unknown 38 | ``` 39 | 40 | ### Run 41 | 42 | - Use a web browser and navigate to 43 | - Open the console to see execution logs 44 | -------------------------------------------------------------------------------- /LICENSE-MIT: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. 20 | -------------------------------------------------------------------------------- /matchbox_signaling/examples/full_mesh.rs: -------------------------------------------------------------------------------- 1 | use matchbox_signaling::SignalingServer; 2 | use std::net::Ipv4Addr; 3 | use tracing::info; 4 | 5 | #[tokio::main] 6 | async fn main() -> Result<(), matchbox_signaling::Error> { 7 | setup_logging(); 8 | 9 | let server = SignalingServer::full_mesh_builder((Ipv4Addr::UNSPECIFIED, 3536)) 10 | .on_connection_request(|connection| { 11 | info!("Connecting: {connection:?}"); 12 | Ok(true) // Allow all connections 13 | }) 14 | .on_id_assignment(|(socket, id)| info!("{socket} received {id}")) 15 | .on_peer_connected(|id| info!("Joined: {id}")) 16 | .on_peer_disconnected(|id| info!("Left: {id}")) 17 | .cors() 18 | .trace() 19 | .build(); 20 | server.serve().await 21 | } 22 | 23 | fn setup_logging() { 24 | use tracing_subscriber::prelude::*; 25 | tracing_subscriber::registry() 26 | .with( 27 | tracing_subscriber::EnvFilter::try_from_default_env() 28 | .unwrap_or_else(|_| "debug".into()), 29 | ) 30 | .with(tracing_subscriber::fmt::layer()) 31 | .init(); 32 | } 33 | -------------------------------------------------------------------------------- /matchbox_signaling/src/signaling_server/error.rs: -------------------------------------------------------------------------------- 1 | use axum::extract::ws::Message; 2 | use tokio::sync::mpsc::error::SendError; 3 | 4 | /// An error derived from a client's request. 5 | #[derive(Debug, thiserror::Error)] 6 | pub enum ClientRequestError { 7 | /// An error originating from Axum 8 | #[error("Axum error: {0}")] 9 | Axum(#[from] axum::Error), 10 | 11 | /// The socket is closed 12 | #[error("Socket is closed.")] 13 | Close, 14 | 15 | /// Message received was not JSON 16 | #[error("Json error: {0}")] 17 | Json(#[from] serde_json::Error), 18 | 19 | /// Unsupported message type (not JSON) 20 | #[error("Unsupported message type: {0:?}")] 21 | UnsupportedType(Message), 22 | } 23 | 24 | /// An error in server logic. 25 | #[derive(Debug, thiserror::Error)] 26 | pub enum SignalingError { 27 | /// The recipient peer is unknown 28 | #[error("Unknown recipient peer")] 29 | UnknownPeer, 30 | 31 | /// The message was undeliverable (socket may be closed or a future was dropped prematurely) 32 | #[error("Undeliverable message: {0}")] 33 | Undeliverable(#[from] SendError>), 34 | } 35 | -------------------------------------------------------------------------------- /examples/error_handling/README.md: -------------------------------------------------------------------------------- 1 | # Error handling example 2 | 3 | This example shows one way failures can be handled, logging at the appropriate points. 4 | 5 | The example tries to connect to a room, then sends messages to all peers as quickly as possible, logging any messages received, then disconnects after a timeout. 6 | 7 | ## Instructions 8 | 9 | - Run the matchbox-provided [`matchbox_server`](../../matchbox_server/) ([help](../../matchbox_server/README.md)), or run your own on `ws://localhost:3536/`. 10 | - Run the demo 11 | - [on Native](#run-on-native) 12 | - [on WASM](#run-on-wasm) 13 | 14 | ## Run on Native 15 | 16 | ```sh 17 | cargo run 18 | ``` 19 | 20 | ## Run on WASM 21 | 22 | ### Prerequisites 23 | 24 | Install the `wasm32-unknown-unknown` target 25 | 26 | ```sh 27 | rustup target install wasm32-unknown-unknown 28 | ``` 29 | 30 | Install a lightweight web server 31 | 32 | ```sh 33 | cargo install wasm-server-runner 34 | ``` 35 | 36 | ### Serve 37 | 38 | ```sh 39 | cargo run --target wasm32-unknown-unknown 40 | ``` 41 | 42 | ### Run 43 | 44 | - Use a web browser and navigate to 45 | - Open the console to see execution logs 46 | -------------------------------------------------------------------------------- /matchbox_protocol/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "matchbox_protocol" 3 | version = "0.13.0" 4 | authors = [ 5 | "Johan Helsing ", 6 | "Spencer C. Imbleau ", 7 | ] 8 | description = "Common interfaces between matchbox_socket and matchbox_server" 9 | edition = "2024" 10 | license = "MIT OR Apache-2.0" 11 | keywords = ["gamedev", "webrtc", "peer-to-peer", "networking", "wasm"] 12 | categories = [ 13 | "network-programming", 14 | "game-development", 15 | "wasm", 16 | "web-programming", 17 | ] 18 | repository = "https://github.com/johanhelsing/matchbox" 19 | homepage = "https://github.com/johanhelsing/matchbox" 20 | readme = "../README.md" 21 | 22 | [features] 23 | json = ["dep:serde_json"] 24 | 25 | [dependencies] 26 | cfg-if = "1.0" 27 | serde = { version = "1.0", features = ["derive"] } 28 | uuid = { version = "1.4", features = ["serde"] } 29 | derive_more = { version = "2.0", features = ["display", "from"] } 30 | 31 | # JSON feature 32 | serde_json = { version = "1.0", default-features = false, optional = true } 33 | 34 | [target.'cfg(target_arch = "wasm32")'.dependencies] 35 | uuid = { version = "1.4", features = ["js"] } 36 | -------------------------------------------------------------------------------- /matchbox_signaling/examples/client_server.rs: -------------------------------------------------------------------------------- 1 | use matchbox_signaling::SignalingServer; 2 | use std::net::Ipv4Addr; 3 | use tracing::info; 4 | 5 | #[tokio::main] 6 | async fn main() -> Result<(), matchbox_signaling::Error> { 7 | setup_logging(); 8 | 9 | let server = SignalingServer::client_server_builder((Ipv4Addr::UNSPECIFIED, 3536)) 10 | .on_connection_request(|connection| { 11 | info!("Connecting: {connection:?}"); 12 | Ok(true) // Allow all connections 13 | }) 14 | .on_id_assignment(|(socket, id)| info!("{socket} received {id}")) 15 | .on_host_connected(|id| info!("Host joined: {id}")) 16 | .on_host_disconnected(|id| info!("Host left: {id}")) 17 | .on_client_connected(|id| info!("Client joined: {id}")) 18 | .on_client_disconnected(|id| info!("Client left: {id}")) 19 | .cors() 20 | .trace() 21 | .build(); 22 | server.serve().await 23 | } 24 | 25 | fn setup_logging() { 26 | use tracing_subscriber::prelude::*; 27 | tracing_subscriber::registry() 28 | .with( 29 | tracing_subscriber::EnvFilter::try_from_default_env() 30 | .unwrap_or_else(|_| "debug".into()), 31 | ) 32 | .with(tracing_subscriber::fmt::layer()) 33 | .init(); 34 | } 35 | -------------------------------------------------------------------------------- /examples/async_example/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "async_example" 3 | version = "0.1.0" 4 | edition = "2024" 5 | publish = false 6 | 7 | [dependencies] 8 | matchbox_socket = { path = "../../matchbox_socket" } 9 | 10 | futures-timer = { version = "3", features = ["wasm-bindgen"] } 11 | n0-future = { version = "0.2", features = [] } 12 | uuid = { version = "1.16", features = ["v4", "rng-getrandom"] } 13 | anyhow = "1.0" 14 | serde = { version = "1.0", features = ["derive"] } 15 | serde_json = "1.0" 16 | web-time = "1.1.0" 17 | js-sys = "0.3.77" 18 | tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] } 19 | tracing = { version = "0.1" } 20 | async-broadcast = "0.7" 21 | log = "0.4" 22 | 23 | [target.'cfg(target_arch = "wasm32")'.dependencies] 24 | console_error_panic_hook = "0.1.7" 25 | console_log = "1.0" 26 | futures = { version = "0.3", default-features = false } 27 | wasm-bindgen-futures = "0.4.29" 28 | tokio = { version = "1.32", default-features = false, features = [ 29 | "time", 30 | "sync", 31 | ] } 32 | getrandom = { version = "0.3", features = ["wasm_js"] } 33 | tracing-wasm = "0.2.1" 34 | wasm-bindgen = "0.2" 35 | web-sys = { version = "0.3", features = ["Window", "Location"] } 36 | 37 | [target.'cfg(not(target_arch = "wasm32"))'.dependencies] 38 | futures = "0.3" 39 | tokio = "1.32" 40 | -------------------------------------------------------------------------------- /examples/bevy_ggrs/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "bevy_ggrs_example" 3 | version = "0.7.0" 4 | authors = ["Johan Helsing "] 5 | description = "A demo game where two web browser can connect and move boxes around" 6 | edition = "2024" 7 | repository = "https://github.com/johanhelsing/matchbox" 8 | keywords = ["gamedev", "webrtc", "peer-to-peer", "networking", "wasm"] 9 | license = "MIT OR Apache-2.0" 10 | publish = false 11 | 12 | [target.'cfg(target_arch = "wasm32")'.dependencies] 13 | web-sys = { version = "0.3", features = [ 14 | "Document", 15 | "Location", # for getting args from query string 16 | ] } 17 | serde_qs = "0.15" 18 | wasm-bindgen = "0.2" 19 | bevy_ggrs = { version = "0.19", features = ["wasm-bindgen"] } 20 | 21 | [dependencies] 22 | bevy_matchbox = { path = "../../bevy_matchbox", features = ["ggrs"] } 23 | bevy = { version = "0.17", default-features = false, features = [ 24 | "bevy_winit", 25 | "bevy_render", 26 | "bevy_pbr", 27 | "bevy_core_pipeline", 28 | "bevy_ui_render", 29 | "bevy_text", 30 | "bevy_asset", 31 | "bevy_sprite_render", 32 | "bevy_state", 33 | "multi_threaded", 34 | "png", 35 | "zstd_rust", 36 | "webgl2", 37 | "tonemapping_luts", 38 | # gh actions runners don't like wayland 39 | "x11", 40 | ] } 41 | bevy_ggrs = "0.19" 42 | clap = { version = "4.3", features = ["derive"] } 43 | serde = "1.0" 44 | -------------------------------------------------------------------------------- /matchbox_signaling/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "matchbox_signaling" 3 | version = "0.13.0" 4 | authors = [ 5 | "Johan Helsing ", 6 | "Spencer C. Imbleau ", 7 | ] 8 | description = "Painless WebRTC peer-to-peer signaling servers" 9 | edition = "2024" 10 | license = "MIT OR Apache-2.0" 11 | keywords = ["gamedev", "webrtc", "peer-to-peer", "networking", "wasm"] 12 | categories = [ 13 | "network-programming", 14 | "game-development", 15 | "wasm", 16 | "web-programming", 17 | ] 18 | repository = "https://github.com/johanhelsing/matchbox" 19 | 20 | [dependencies] 21 | matchbox_protocol = { version = "0.13", path = "../matchbox_protocol", features = [ 22 | "json", 23 | ] } 24 | axum = { version = "0.8", features = ["ws"] } 25 | hyper = { version = "1.2", features = ["server"] } 26 | tracing = { version = "0.1", features = ["log"] } 27 | tower-http = { version = "0.6", features = ["cors", "trace"] } 28 | tokio = { version = "1.32", features = ["macros", "rt-multi-thread"] } 29 | serde = { version = "1.0", features = ["derive"] } 30 | serde_json = "1.0" 31 | futures = { version = "0.3", default-features = false, features = ["alloc"] } 32 | uuid = { version = "1.4", features = ["serde", "v4"] } 33 | thiserror = "2.0" 34 | tokio-stream = "0.1" 35 | async-trait = "0.1" 36 | 37 | [dev-dependencies] 38 | tokio-tungstenite = "0.28" 39 | tracing-subscriber = { version = "0.3", features = ["env-filter"] } 40 | -------------------------------------------------------------------------------- /examples/custom_signaller/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "custom_signaller" 3 | version = "0.1.0" 4 | edition = "2021" 5 | publish = false 6 | 7 | [dependencies] 8 | matchbox_socket = { path = "../../matchbox_socket" } 9 | 10 | futures-timer = { version = "3", features = ["wasm-bindgen"] } 11 | iroh = { version = "0.35", default-features = false } 12 | iroh-gossip = { version = "0.35", default-features = false, features = ["net"] } 13 | n0-future = { version = "0.2", features = [] } 14 | uuid = { version = "1.16", features = ["v4", "rng-getrandom"] } 15 | anyhow = "1.0" 16 | serde = { version = "1.0", features = ["derive"] } 17 | serde_json = "1.0" 18 | async-broadcast = "0.7.2" 19 | web-time = "1.1.0" 20 | js-sys = "0.3.77" 21 | tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] } 22 | tracing = { version = "0.1" } 23 | 24 | [target.'cfg(target_arch = "wasm32")'.dependencies] 25 | console_error_panic_hook = "0.1.7" 26 | console_log = "1.0" 27 | futures = { version = "0.3", default-features = false } 28 | wasm-bindgen-futures = "0.4.29" 29 | tokio = { version = "1.32", default-features = false, features = [ 30 | "time", 31 | "sync", 32 | ] } 33 | getrandom = { version = "0.3", features = ["wasm_js"] } 34 | tracing-wasm = "0.2.1" 35 | wasm-bindgen = "0.2" 36 | web-sys = { version = "0.3", features = ["Window", "Location"] } 37 | 38 | [target.'cfg(not(target_arch = "wasm32"))'.dependencies] 39 | futures = "0.3" 40 | tokio = "1.32" 41 | -------------------------------------------------------------------------------- /examples/bevy_ggrs/src/args.rs: -------------------------------------------------------------------------------- 1 | use bevy::prelude::*; 2 | use clap::Parser; 3 | use serde::Deserialize; 4 | use std::ffi::OsString; 5 | 6 | #[derive(Parser, Debug, Clone, Deserialize, Resource)] 7 | #[serde(default)] 8 | #[clap( 9 | name = "box_game_web", 10 | rename_all = "kebab-case", 11 | rename_all_env = "screaming-snake" 12 | )] 13 | pub struct Args { 14 | #[clap(long, default_value = "ws://127.0.0.1:3536")] 15 | pub matchbox: String, 16 | 17 | #[clap(long)] 18 | pub room: Option, 19 | 20 | #[clap(long, short, default_value = "2")] 21 | pub players: usize, 22 | } 23 | 24 | impl Default for Args { 25 | fn default() -> Self { 26 | let args = Vec::::new(); 27 | Args::parse_from(args) 28 | } 29 | } 30 | 31 | impl Args { 32 | pub fn get() -> Self { 33 | #[cfg(target_arch = "wasm32")] 34 | { 35 | let qs = web_sys::window() 36 | .unwrap() 37 | .location() 38 | .search() 39 | .unwrap() 40 | .trim_start_matches('?') 41 | .to_owned(); 42 | 43 | Args::from_query(&qs) 44 | } 45 | #[cfg(not(target_arch = "wasm32"))] 46 | { 47 | Args::parse() 48 | } 49 | } 50 | 51 | // #[allow(dead_code)] 52 | #[cfg(target_arch = "wasm32")] 53 | fn from_query(query: &str) -> Self { 54 | // TODO: result? 55 | serde_qs::from_str(query).unwrap() 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /matchbox_server/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "matchbox_server" 3 | version = "0.13.0" 4 | authors = ["Johan Helsing "] 5 | edition = "2024" 6 | description = "A signaling server for WebRTC peer-to-peer full-mesh networking" 7 | license = "MIT OR Apache-2.0" 8 | keywords = ["gamedev", "webrtc", "peer-to-peer", "networking", "wasm"] 9 | categories = [ 10 | "network-programming", 11 | "game-development", 12 | "wasm", 13 | "web-programming", 14 | ] 15 | repository = "https://github.com/johanhelsing/matchbox" 16 | homepage = "https://github.com/johanhelsing/matchbox" 17 | readme = "../README.md" 18 | 19 | [dependencies] 20 | matchbox_signaling = { version = "0.13", path = "../matchbox_signaling" } 21 | matchbox_protocol = { version = "0.13", path = "../matchbox_protocol", features = [ 22 | "json", 23 | ] } 24 | async-trait = "0.1" 25 | axum = { version = "0.8", features = ["ws"] } 26 | tracing = { version = "0.1", features = ["log"] } 27 | tracing-subscriber = { version = "0.3", features = ["env-filter"] } 28 | tower-http = { version = "0.6", features = ["cors", "trace"] } 29 | tokio = { version = "1.32", features = ["macros", "rt-multi-thread"] } 30 | serde = { version = "1.0", features = ["derive"] } 31 | serde_json = "1.0" 32 | futures = { version = "0.3", default-features = false, features = ["alloc"] } 33 | uuid = { version = "1.4", features = ["serde", "v4"] } 34 | clap = { version = "4.3", features = ["derive", "env"] } 35 | thiserror = "2.0" 36 | tokio-stream = "0.1" 37 | 38 | [dev-dependencies] 39 | tokio-tungstenite = "0.28" 40 | -------------------------------------------------------------------------------- /examples/custom_signaller/README.md: -------------------------------------------------------------------------------- 1 | # Custom signaller example 2 | 3 | Shows how to use `matchbox_socket` with a custom signaller implementation; In 4 | this case, the iroh p2p network. 5 | 6 | ## Instructions 7 | 8 | - No need for matchbox server; signaling will be done through Iroh P2P Network 9 | - extra dependencies: clang 10 | - Windows: , `LLVM-20.1.1-win64.exe`, check "add to path for all users" 11 | - Linux (Ubuntu/debian-based): `sudo apt install clang` 12 | - MacOS: `brew install llvm` 13 | - Run the demo 14 | - [on Native](#run-on-native) 15 | - [on WASM](#run-on-wasm) 16 | 17 | ## Run on Native 18 | 19 | First node: 20 | 21 | ```sh 22 | cargo run 23 | ``` 24 | 25 | Then read from terminal the Iroh ID to pass to subsequent nodes: 26 | 27 | Second node: 28 | 29 | ```sh 30 | cargo run -- "000DEADBEEF....FFF" 31 | ``` 32 | 33 | ## Run on WASM 34 | 35 | ### Prerequisites 36 | 37 | Install the `wasm32-unknown-unknown` target 38 | 39 | ```sh 40 | rustup target install wasm32-unknown-unknown 41 | ``` 42 | 43 | Install a lightweight web server 44 | 45 | ```sh 46 | cargo install wasm-server-runner 47 | ``` 48 | 49 | ### Serve 50 | 51 | ```sh 52 | RUSTFLAGS='--cfg getrandom_backend="wasm_js"' cargo run --target wasm32-unknown-unknown 53 | ``` 54 | 55 | ### Run 56 | 57 | - Use a web browser and navigate to 58 | - Open the console to see the Iroh ID and room join URL 59 | - In a second browser tab, open the room join URL. It should look like this: 60 | -------------------------------------------------------------------------------- /.github/workflows/website.yml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | branches: 4 | - main 5 | 6 | name: Website 7 | 8 | jobs: 9 | build: 10 | name: Build Examples 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Checkout sources 14 | uses: actions/checkout@v6 15 | 16 | - name: Install stable toolchain 17 | uses: dtolnay/rust-toolchain@stable 18 | with: 19 | toolchain: stable 20 | target: wasm32-unknown-unknown 21 | 22 | #- name: Rust Cache 23 | # uses: Swatinem/rust-cache@v2.5.0 24 | 25 | - name: Install bevy_wasm_pack 26 | run: cargo install --git https://github.com/johanhelsing/bevy_wasm_pack 27 | 28 | - name: Build bevy_ggrs example 29 | run: RUSTFLAGS='--cfg=getrandom_backend="wasm_js"' bevy_wasm_pack dist bevy_ggrs_example --dir-name bevy_ggrs 30 | 31 | - name: Copy index.html 32 | run: cp website/index.html dist/index.html 33 | 34 | - name: Copy assets 35 | run: cp -r examples/bevy_ggrs/assets dist/assets 36 | 37 | - name: Upload GitHub Pages artifact 38 | uses: actions/upload-pages-artifact@v4.0.0 39 | with: 40 | path: dist 41 | 42 | deploy: 43 | name: Deploy Pages 44 | needs: 45 | - build 46 | permissions: 47 | pages: write 48 | id-token: write 49 | environment: 50 | name: github-pages 51 | url: ${{ steps.deployment.outputs.page_url }} 52 | runs-on: ubuntu-latest 53 | steps: 54 | - name: Deploy GitHub Pages site 55 | id: deployment 56 | uses: actions/deploy-pages@v4.0.5 57 | -------------------------------------------------------------------------------- /bevy_matchbox/examples/hello.rs: -------------------------------------------------------------------------------- 1 | //! Sends messages periodically to all connected peers (or host if connected in 2 | //! a client server topology). 3 | 4 | use bevy::{prelude::*, time::common_conditions::on_timer}; 5 | use bevy_matchbox::prelude::*; 6 | use core::time::Duration; 7 | 8 | const CHANNEL_ID: usize = 0; 9 | 10 | fn main() { 11 | App::new() 12 | .add_plugins(DefaultPlugins) 13 | .add_systems(Startup, start_socket) 14 | .add_systems(Update, receive_messages) 15 | .add_systems( 16 | Update, 17 | send_message.run_if(on_timer(Duration::from_secs(5))), 18 | ) 19 | .run(); 20 | } 21 | 22 | fn start_socket(mut commands: Commands) { 23 | let socket = MatchboxSocket::new_reliable("ws://localhost:3536/hello"); 24 | commands.insert_resource(socket); 25 | } 26 | 27 | fn send_message(mut socket: ResMut) { 28 | let peers: Vec<_> = socket.connected_peers().collect(); 29 | 30 | for peer in peers { 31 | let message = "Hello"; 32 | info!("Sending message: {message:?} to {peer}"); 33 | socket 34 | .channel_mut(CHANNEL_ID) 35 | .send(message.as_bytes().into(), peer); 36 | } 37 | } 38 | 39 | fn receive_messages(mut socket: ResMut) { 40 | for (peer, state) in socket.update_peers() { 41 | info!("{peer}: {state:?}"); 42 | } 43 | 44 | for (_id, message) in socket.channel_mut(CHANNEL_ID).receive() { 45 | match std::str::from_utf8(&message) { 46 | Ok(message) => info!("Received message: {message:?}"), 47 | Err(e) => error!("Failed to convert message to string: {e}"), 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /examples/bevy_ggrs/README.md: -------------------------------------------------------------------------------- 1 | # Bevy + GGRS 2 | 3 | Shows how to use `matchbox_socket` with `bevy` and `ggrs` using `bevy_matchbox` and `bevy_ggrs`, to create a simple working browser "game" (if moving cubes around on a plane can be called a game). 4 | 5 | ## Live Demo 6 | 7 | There is a live version here (move the cube with WASD): 8 | 9 | - 2-Player: 10 | - 3-Player: 11 | - N-player: Edit the link above. 12 | 13 | When enough players have joined, you should see a couple of boxes, one of which 14 | you can move around using the `WASD` keys. 15 | 16 | You can open the browser console to get some rough idea about what's happening 17 | (or not happening if that's the unfortunate case). 18 | 19 | ## Instructions 20 | 21 | - Run the matchbox-provided [`matchbox_server`](../../matchbox_server/) ([help](../../matchbox_server/README.md)), or run your own on `ws://localhost:3536/`. 22 | - Run the demo (enough clients must connect before the game stats) 23 | - [on Native](#run-on-native) 24 | - [on WASM](#run-on-wasm) 25 | 26 | ## Run on Native 27 | 28 | ```sh 29 | cargo run -- [--matchbox ws://127.0.0.1:3536] [--players 2] [--room ] 30 | ``` 31 | 32 | ## Run on WASM 33 | 34 | ### Prerequisites 35 | 36 | Install the `wasm32-unknown-unknown` target 37 | 38 | ```sh 39 | rustup target install wasm32-unknown-unknown 40 | ``` 41 | 42 | Install a lightweight web server 43 | 44 | ```sh 45 | cargo install wasm-server-runner 46 | ``` 47 | 48 | ### Serve 49 | 50 | ```sh 51 | cargo run --target wasm32-unknown-unknown 52 | ``` 53 | 54 | ### Run 55 | 56 | - Use a web browser and navigate to 57 | - Open the console to see execution logs 58 | -------------------------------------------------------------------------------- /bevy_matchbox/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "bevy_matchbox" 3 | version = "0.13.0" 4 | authors = [ 5 | "Johan Helsing ", 6 | "Garry O'Donnell ); 9 | 10 | type DirectMessage = (PeerEvent, u128); 11 | 12 | impl DirectMessageProtocol { 13 | async fn handle_connection(self, connection: Connection) -> anyhow::Result<()> { 14 | let _remote_node_id = connection.remote_node_id()?; 15 | let mut recv = connection.accept_uni().await?; 16 | let data = recv.read_to_end(1024 * 63).await?; 17 | connection.close(0u8.into(), b"done"); 18 | let data: DirectMessage = serde_json::from_slice(&data)?; 19 | let data = match &data.0 { 20 | PeerEvent::Signal { .. } => (_remote_node_id, data), 21 | _ => { 22 | anyhow::bail!("unsupported event type: {:?}", data) 23 | } 24 | }; 25 | self.0.broadcast((data.0, data.1 .0)).await?; 26 | Ok(()) 27 | } 28 | } 29 | 30 | impl ProtocolHandler for DirectMessageProtocol { 31 | fn accept(&self, connection: Connection) -> n0_future::boxed::BoxFuture> { 32 | Box::pin(self.clone().handle_connection(connection)) 33 | } 34 | } 35 | pub async fn send_direct_message( 36 | endpoint: &Endpoint, 37 | iroh_target: PublicKey, 38 | payload: PeerEvent, 39 | ) -> anyhow::Result<()> { 40 | let connection = endpoint.connect(iroh_target, DIRECT_MESSAGE_ALPN).await?; 41 | let payload: DirectMessage = (payload, get_timestamp()); 42 | let payload = serde_json::to_vec(&payload)?; 43 | let mut send_stream = connection.open_uni().await?; 44 | send_stream.write_all(&payload).await?; 45 | send_stream.finish()?; 46 | connection.closed().await; 47 | // connection.close(0u8.into(), b"done"); 48 | Ok(()) 49 | } 50 | -------------------------------------------------------------------------------- /matchbox_protocol/src/lib.rs: -------------------------------------------------------------------------------- 1 | use cfg_if::cfg_if; 2 | use derive_more::{Display, From}; 3 | use serde::{Deserialize, Serialize}; 4 | use uuid::Uuid; 5 | 6 | /// The format for a peer signature given by the signaling server 7 | #[derive( 8 | Debug, Display, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, From, Hash, PartialOrd, Ord, 9 | )] 10 | pub struct PeerId(pub Uuid); 11 | 12 | /// Requests go from peer to signaling server 13 | #[derive(Debug, Serialize, Deserialize, PartialEq, Eq)] 14 | pub enum PeerRequest { 15 | Signal { receiver: PeerId, data: S }, 16 | KeepAlive, 17 | } 18 | 19 | /// Events go from signaling server to peer 20 | #[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone)] 21 | pub enum PeerEvent { 22 | /// Sent by the server to the connecting peer, immediately after connection 23 | /// before any other events 24 | IdAssigned(PeerId), 25 | NewPeer(PeerId), 26 | PeerLeft(PeerId), 27 | Signal { 28 | sender: PeerId, 29 | data: S, 30 | }, 31 | } 32 | 33 | cfg_if! { 34 | if #[cfg(feature = "json")] { 35 | pub type JsonPeerRequest = PeerRequest; 36 | pub type JsonPeerEvent = PeerEvent; 37 | use std::fmt; 38 | 39 | 40 | impl fmt::Display for JsonPeerRequest { 41 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 42 | write!(f, "{}", serde_json::to_string(self).map_err(|_| fmt::Error)?) 43 | } 44 | } 45 | impl std::str::FromStr for JsonPeerRequest { 46 | type Err = serde_json::Error; 47 | 48 | fn from_str(s: &str) -> Result { 49 | serde_json::from_str(s) 50 | } 51 | } 52 | 53 | impl fmt::Display for JsonPeerEvent { 54 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 55 | write!(f, "{}", serde_json::to_string(self).map_err(|_| fmt::Error)?) 56 | } 57 | } 58 | impl std::str::FromStr for JsonPeerEvent { 59 | type Err = serde_json::Error; 60 | 61 | fn from_str(s: &str) -> Result { 62 | serde_json::from_str(s) 63 | } 64 | } 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /matchbox_signaling/src/signaling_server/callbacks.rs: -------------------------------------------------------------------------------- 1 | use crate::signaling_server::handlers::WsUpgradeMeta; 2 | use axum::response::Response; 3 | use matchbox_protocol::PeerId; 4 | use std::{ 5 | fmt, 6 | net::SocketAddr, 7 | sync::{Arc, Mutex}, 8 | }; 9 | 10 | /// Universal callback wrapper. 11 | /// 12 | /// An `Arc` wrapper is used to make it cloneable and thread safe. 13 | pub struct Callback { 14 | /// A callback which can be called multiple times 15 | pub(crate) cb: Arc Out + Send + Sync>>, 16 | } 17 | 18 | impl Out + Send + Sync + 'static> From for Callback { 19 | fn from(func: F) -> Self { 20 | Callback { 21 | cb: Arc::new(Mutex::new(func)), 22 | } 23 | } 24 | } 25 | 26 | impl Clone for Callback { 27 | fn clone(&self) -> Self { 28 | Self { 29 | cb: self.cb.clone(), 30 | } 31 | } 32 | } 33 | 34 | impl fmt::Debug for Callback { 35 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 36 | write!(f, "Callback<_>") 37 | } 38 | } 39 | 40 | impl Callback { 41 | /// This method calls the callback's function. 42 | pub fn emit(&self, value: In) -> Out { 43 | let mut lock = self.cb.lock().expect("lock"); 44 | (*lock)(value) 45 | } 46 | } 47 | 48 | impl Callback { 49 | /// Creates a "no-op" callback which can be used when it is not suitable to use an 50 | /// `Option`. 51 | pub fn noop() -> Self { 52 | Self::from(|_| ()) 53 | } 54 | } 55 | 56 | impl Default for Callback { 57 | fn default() -> Self { 58 | Self::noop() 59 | } 60 | } 61 | 62 | /// Signaling callbacks for all topologies 63 | #[derive(Debug, Clone)] 64 | pub struct SharedCallbacks { 65 | /// Triggered before websocket upgrade to determine if the connection is allowed. 66 | pub(crate) on_connection_request: Callback>, 67 | 68 | /// Triggered on ID assignment for a socket. 69 | pub(crate) on_id_assignment: Callback<(SocketAddr, PeerId)>, 70 | } 71 | 72 | impl Default for SharedCallbacks { 73 | fn default() -> Self { 74 | Self { 75 | on_connection_request: Callback::from(|_| Ok(true)), 76 | on_id_assignment: Callback::default(), 77 | } 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /matchbox_server/src/main.rs: -------------------------------------------------------------------------------- 1 | mod args; 2 | mod state; 3 | mod topology; 4 | 5 | use crate::{ 6 | state::{RequestedRoom, RoomId, ServerState}, 7 | topology::MatchmakingDemoTopology, 8 | }; 9 | use args::Args; 10 | use axum::{http::StatusCode, response::IntoResponse, routing::get}; 11 | use clap::Parser; 12 | use matchbox_signaling::SignalingServerBuilder; 13 | use tracing::info; 14 | use tracing_subscriber::prelude::*; 15 | 16 | fn setup_logging() { 17 | tracing_subscriber::registry() 18 | .with( 19 | tracing_subscriber::EnvFilter::try_from_default_env() 20 | .unwrap_or_else(|_| "matchbox_server=info,tower_http=debug".into()), 21 | ) 22 | .with( 23 | tracing_subscriber::fmt::layer() 24 | .compact() 25 | .with_file(false) 26 | .with_target(false), 27 | ) 28 | .init(); 29 | } 30 | 31 | #[tokio::main] 32 | async fn main() { 33 | setup_logging(); 34 | let args = Args::parse(); 35 | 36 | // Setup router 37 | info!("Matchbox Signaling Server: {}", args.host); 38 | 39 | let mut state = ServerState::default(); 40 | let server = SignalingServerBuilder::new(args.host, MatchmakingDemoTopology, state.clone()) 41 | .on_connection_request({ 42 | let mut state = state.clone(); 43 | move |connection| { 44 | let room_id = RoomId(connection.path.clone().unwrap_or_default()); 45 | let next = connection 46 | .query_params 47 | .get("next") 48 | .and_then(|next| next.parse::().ok()); 49 | let room = RequestedRoom { id: room_id, next }; 50 | state.add_waiting_client(connection.origin, room); 51 | Ok(true) // allow all clients 52 | } 53 | }) 54 | .on_id_assignment({ 55 | move |(origin, peer_id)| { 56 | info!("Client connected {origin:?}: {peer_id:?}"); 57 | state.assign_id_to_waiting_client(origin, peer_id); 58 | } 59 | }) 60 | .cors() 61 | .trace() 62 | .mutate_router(|router| router.route("/health", get(health_handler))) 63 | .build(); 64 | server 65 | .serve() 66 | .await 67 | .expect("Unable to run signaling server, is it already running?") 68 | } 69 | 70 | pub async fn health_handler() -> impl IntoResponse { 71 | StatusCode::OK 72 | } 73 | -------------------------------------------------------------------------------- /examples/simple/src/main.rs: -------------------------------------------------------------------------------- 1 | use futures::{FutureExt, select}; 2 | use futures_timer::Delay; 3 | use log::info; 4 | use matchbox_socket::{PeerState, WebRtcSocket}; 5 | use std::time::Duration; 6 | 7 | const CHANNEL_ID: usize = 0; 8 | 9 | #[cfg(target_arch = "wasm32")] 10 | fn main() { 11 | // Setup logging 12 | console_error_panic_hook::set_once(); 13 | console_log::init_with_level(log::Level::Debug).unwrap(); 14 | 15 | wasm_bindgen_futures::spawn_local(async_main()); 16 | } 17 | 18 | #[cfg(not(target_arch = "wasm32"))] 19 | #[tokio::main] 20 | async fn main() { 21 | // Setup logging 22 | use tracing_subscriber::prelude::*; 23 | tracing_subscriber::registry() 24 | .with( 25 | tracing_subscriber::EnvFilter::try_from_default_env() 26 | .unwrap_or_else(|_| "simple_example=info,matchbox_socket=info".into()), 27 | ) 28 | .with(tracing_subscriber::fmt::layer()) 29 | .init(); 30 | 31 | async_main().await 32 | } 33 | 34 | async fn async_main() { 35 | info!("Connecting to matchbox"); 36 | let (mut socket, loop_fut) = WebRtcSocket::new_reliable("ws://localhost:3536/"); 37 | 38 | let loop_fut = loop_fut.fuse(); 39 | futures::pin_mut!(loop_fut); 40 | 41 | let timeout = Delay::new(Duration::from_millis(100)); 42 | futures::pin_mut!(timeout); 43 | 44 | loop { 45 | // Handle any new peers 46 | for (peer, state) in socket.update_peers() { 47 | match state { 48 | PeerState::Connected => { 49 | info!("Peer joined: {peer}"); 50 | let packet = "hello friend!".as_bytes().to_vec().into_boxed_slice(); 51 | socket.channel_mut(CHANNEL_ID).send(packet, peer); 52 | } 53 | PeerState::Disconnected => { 54 | info!("Peer left: {peer}"); 55 | } 56 | } 57 | } 58 | 59 | // Accept any messages incoming 60 | for (peer, packet) in socket.channel_mut(CHANNEL_ID).receive() { 61 | let message = String::from_utf8_lossy(&packet); 62 | info!("Message from {peer}: {message:?}"); 63 | } 64 | 65 | select! { 66 | // Restart this loop every 100ms 67 | _ = (&mut timeout).fuse() => { 68 | timeout.reset(Duration::from_millis(100)); 69 | } 70 | 71 | // Or break if the message loop ends (disconnected, closed, etc.) 72 | _ = &mut loop_fut => { 73 | break; 74 | } 75 | } 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /matchbox_socket/src/ggrs_socket.rs: -------------------------------------------------------------------------------- 1 | use ggrs::{Message, PlayerType}; 2 | use matchbox_protocol::PeerId; 3 | 4 | use crate::{Packet, WebRtcChannel, WebRtcSocket}; 5 | 6 | impl WebRtcSocket { 7 | /// Returns a Vec of connected peers as [`ggrs::PlayerType`] 8 | pub fn players(&mut self) -> Vec> { 9 | let Some(our_id) = self.id() else { 10 | // we're still waiting for the server to initialize our id 11 | // no peers should be added at this point anyway 12 | return vec![PlayerType::Local]; 13 | }; 14 | 15 | // player order needs to be consistent order across all peers 16 | let mut ids: Vec<_> = self 17 | .connected_peers() 18 | .chain(std::iter::once(our_id)) 19 | .collect(); 20 | ids.sort(); 21 | 22 | ids.into_iter() 23 | .map(|id| { 24 | if id == our_id { 25 | PlayerType::Local 26 | } else { 27 | PlayerType::Remote(id) 28 | } 29 | }) 30 | .collect() 31 | } 32 | } 33 | 34 | fn build_packet(msg: &Message) -> Packet { 35 | bincode::serde::encode_to_vec(msg, bincode::config::standard()) 36 | .expect("failed to serialize ggrs packet") 37 | .into_boxed_slice() 38 | } 39 | 40 | fn deserialize_packet(message: (PeerId, Packet)) -> (PeerId, Message) { 41 | ( 42 | message.0, 43 | bincode::serde::decode_from_slice(&message.1, bincode::config::standard()) 44 | .expect("failed to deserialize ggrs packet") 45 | .0, 46 | ) 47 | } 48 | 49 | impl ggrs::NonBlockingSocket for WebRtcChannel { 50 | fn send_to(&mut self, msg: &Message, addr: &PeerId) { 51 | if self.config().max_retransmits != Some(0) || self.config().ordered { 52 | // Using a reliable or ordered channel with ggrs is wasteful in that ggrs implements its 53 | // own reliability layer (including unconfirmed inputs in frames) and can 54 | // handle out of order messages just fine on its own. 55 | // It's likely that in poor network conditions this will cause GGRS to unnecessarily 56 | // delay confirming or rolling back simulation frames, which will impact performance 57 | // (or, worst case, cause GGRS to temporarily stop advancing frames). 58 | // So we better warn the user about this. 59 | log::warn!( 60 | "Sending GGRS traffic over reliable or ordered channel ({:?}), which may reduce performance.\ 61 | You should use an unreliable and unordered channel instead.", 62 | self.config() 63 | ); 64 | } 65 | self.send(build_packet(msg), *addr); 66 | } 67 | 68 | fn receive_all_messages(&mut self) -> Vec<(PeerId, Message)> { 69 | self.receive().into_iter().map(deserialize_packet).collect() 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /matchbox_signaling/src/signaling_server/server.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | signaling_server::builder::SignalingServerBuilder, 3 | topologies::{ 4 | client_server::{ClientServer, ClientServerCallbacks, ClientServerState}, 5 | full_mesh::{FullMesh, FullMeshCallbacks, FullMeshState}, 6 | }, 7 | }; 8 | use axum::{Router, extract::connect_info::IntoMakeServiceWithConnectInfo}; 9 | use std::net::{SocketAddr, TcpListener}; 10 | use tokio::net as tokio; 11 | 12 | /// Contains the interface end of a signaling server 13 | #[derive(Debug)] 14 | pub struct SignalingServer { 15 | /// The socket configured for this server 16 | pub(crate) requested_addr: SocketAddr, 17 | 18 | /// Low-level info for how to build an axum server 19 | pub(crate) info: IntoMakeServiceWithConnectInfo, 20 | 21 | pub(crate) listener: Option, 22 | } 23 | 24 | /// Common methods 25 | impl SignalingServer { 26 | /// Creates a new builder for a [`SignalingServer`] with full-mesh topology. 27 | pub fn full_mesh_builder( 28 | socket_addr: impl Into, 29 | ) -> SignalingServerBuilder { 30 | SignalingServerBuilder::new(socket_addr, FullMesh, FullMeshState::default()) 31 | } 32 | 33 | /// Creates a new builder for a [`SignalingServer`] with client-server topology. 34 | pub fn client_server_builder( 35 | socket_addr: impl Into, 36 | ) -> SignalingServerBuilder { 37 | SignalingServerBuilder::new(socket_addr, ClientServer, ClientServerState::default()) 38 | } 39 | 40 | /// Returns the local address this server is bound to 41 | /// 42 | /// The server needs to [`bind`] first 43 | pub fn local_addr(&self) -> Option { 44 | self.listener.as_ref().map(|l| l.local_addr().unwrap()) 45 | } 46 | 47 | /// Binds the server to a socket 48 | /// 49 | /// Optional: Will happen automatically on [`serve`] 50 | pub fn bind(&mut self) -> Result { 51 | let listener = TcpListener::bind(self.requested_addr).map_err(crate::Error::Bind)?; 52 | listener.set_nonblocking(true).map_err(crate::Error::Bind)?; 53 | let addr = listener.local_addr().unwrap(); 54 | self.listener = Some(listener); 55 | Ok(addr) 56 | } 57 | 58 | /// Serve the signaling server 59 | /// 60 | /// Will bind if not already bound 61 | pub async fn serve(mut self) -> Result<(), crate::Error> { 62 | match self.listener { 63 | Some(_) => (), 64 | None => _ = self.bind()?, 65 | }; 66 | let listener = 67 | tokio::TcpListener::from_std(self.listener.expect("No listener, this is a bug!")) 68 | .map_err(crate::Error::Bind)?; 69 | axum::serve(listener, self.info) 70 | .await 71 | .map_err(crate::Error::Serve) 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /matchbox_socket/src/webrtc_socket/error.rs: -------------------------------------------------------------------------------- 1 | use crate::webrtc_socket::messages::PeerEvent; 2 | use cfg_if::cfg_if; 3 | 4 | /// An error that can occur when getting a socket's channel through 5 | /// `get_channel`, `take_channel` or `try_update_peers`. 6 | #[derive(Debug, thiserror::Error)] 7 | pub enum ChannelError { 8 | /// Can occur if trying to get a channel with an Id that was not added while building the 9 | /// socket 10 | #[error("This channel was never created")] 11 | NotFound, 12 | /// The channel has already been taken and is no longer on the socket 13 | #[error("This channel has already been taken and is no longer on the socket")] 14 | Taken, 15 | /// Channel might have been opened but later closed, or never opened in the first place. 16 | /// The latter can for example occur when an one calls `try_update_peers` on a socket that was 17 | /// given an invalid room URL. 18 | #[error("This channel is closed.")] 19 | Closed, 20 | } 21 | 22 | /// An error that can occur with WebRTC messaging. See [Signaller]. 23 | #[derive(Debug, thiserror::Error)] 24 | pub enum SignalingError { 25 | /// Common 26 | #[error("failed to send to signaling server: {0}")] 27 | UndeliverableSignal(#[from] futures_channel::mpsc::TrySendError), 28 | 29 | /// Stream Is Exhausted 30 | #[error("The stream is exhausted")] 31 | StreamExhausted, 32 | 33 | /// Message Received In Unknown Format 34 | #[error("Message received in unknown format")] 35 | UnknownFormat, 36 | 37 | /// Failed To Establish Initial Connection 38 | #[error("failed to establish initial connection: {0}")] 39 | NegotiationFailed(#[from] Box), 40 | 41 | /// Native 42 | #[cfg(not(target_arch = "wasm32"))] 43 | #[error("socket failure communicating with signaling server: {0}")] 44 | WebSocket(#[from] async_tungstenite::tungstenite::Error), 45 | 46 | /// WASM 47 | #[cfg(target_arch = "wasm32")] 48 | #[error("socket failure communicating with signaling server: {0}")] 49 | WebSocket(#[from] ws_stream_wasm::WsErr), 50 | 51 | /// User Implementation Error that may be returned from using custom [`SignallerBuilder`] and 52 | /// [`Signaller`] implementations 53 | #[error("User implementation error: {0}")] 54 | UserImplementationError(String), 55 | } 56 | 57 | cfg_if! { 58 | if #[cfg(target_arch = "wasm32")] { 59 | use wasm_bindgen::{JsValue}; 60 | use derive_more::Display; 61 | 62 | // The below is just to wrap Result into something sensible-ish 63 | 64 | pub trait JsErrorExt { 65 | fn efix(self) -> Result; 66 | } 67 | 68 | impl JsErrorExt for Result { 69 | fn efix(self) -> Result { 70 | self.map_err(JsError) 71 | } 72 | } 73 | 74 | #[derive(Debug, Display)] 75 | #[display("{_0:?}")] 76 | pub struct JsError(JsValue); 77 | 78 | impl std::error::Error for JsError {} 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /matchbox_socket/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "matchbox_socket" 3 | version = "0.13.0" 4 | authors = ["Johan Helsing "] 5 | description = "Painless WebRTC peer-to-peer full-mesh networking socket" 6 | edition = "2024" 7 | license = "MIT OR Apache-2.0" 8 | keywords = ["gamedev", "webrtc", "peer-to-peer", "networking", "wasm"] 9 | categories = [ 10 | "network-programming", 11 | "game-development", 12 | "wasm", 13 | "web-programming", 14 | ] 15 | repository = "https://github.com/johanhelsing/matchbox" 16 | 17 | [features] 18 | ggrs = ["dep:bincode", "dep:ggrs"] 19 | 20 | [dependencies] 21 | matchbox_protocol = { version = "0.13", path = "../matchbox_protocol", default-features = false } 22 | futures-channel = { version = "0.3", default-features = false, features = [ 23 | "sink", 24 | ] } 25 | futures = { version = "0.3", default-features = false } 26 | futures-timer = { version = "3.0", default-features = false } 27 | futures-util = { version = "0.3", default-features = false, features = [ 28 | "sink", 29 | "async-await-macro", 30 | "channel", 31 | ] } 32 | serde = { version = "1.0", default-features = false, features = ["derive"] } 33 | serde_json = { version = "1.0", default-features = false, features = ["alloc"] } 34 | log = { version = "0.4", default-features = false } 35 | thiserror = "2.0" 36 | cfg-if = "1.0" 37 | async-trait = "0.1" 38 | once_cell = { version = "1.17", default-features = false, features = [ 39 | "race", 40 | "alloc", 41 | ] } 42 | derive_more = { version = "2.0", features = ["display", "from"] } 43 | tokio-util = { version = "0.7", features = ["io", "compat"] } 44 | 45 | ggrs = { version = "0.11", default-features = false, optional = true } 46 | bincode = { version = "2.0", default-features = false, features = [ 47 | "serde", 48 | "alloc", 49 | ], optional = true } 50 | bytes = { version = "1.1", default-features = false } 51 | 52 | [target.'cfg(target_arch = "wasm32")'.dependencies] 53 | ggrs = { version = "0.11", default-features = false, optional = true, features = [ 54 | "wasm-bindgen", 55 | ] } 56 | ws_stream_wasm = { version = "0.7", default-features = false } 57 | wasm-bindgen-futures = { version = "0.4", default-features = false } 58 | wasm-bindgen = { version = "0.2", default-features = false } 59 | futures-timer = { version = "3.0", default-features = false, features = [ 60 | "wasm-bindgen", 61 | ] } 62 | js-sys = { version = "0.3", default-features = false } 63 | web-sys = { version = "0.3.22", default-features = false, features = [ 64 | "MessageEvent", 65 | "RtcConfiguration", 66 | "RtcDataChannel", 67 | "RtcDataChannelInit", 68 | "RtcDataChannelType", 69 | "RtcIceCandidate", 70 | "RtcIceCandidateInit", 71 | "RtcIceConnectionState", 72 | "RtcIceGatheringState", 73 | "RtcPeerConnection", 74 | "RtcPeerConnectionIceEvent", 75 | "RtcSdpType", 76 | "RtcSessionDescription", 77 | "RtcSessionDescriptionInit", 78 | ] } 79 | serde-wasm-bindgen = "0.6" 80 | 81 | [target.'cfg(not(target_arch = "wasm32"))'.dependencies] 82 | async-tungstenite = { version = "0.31", default-features = false, features = [ 83 | "async-std-runtime", 84 | "async-tls", 85 | ] } 86 | webrtc = { version = "0.14", default-features = false } 87 | async-compat = { version = "0.2", default-features = false } 88 | 89 | [dev-dependencies] 90 | futures-test = "0.3" 91 | -------------------------------------------------------------------------------- /bevy_matchbox/examples/hello_host.rs: -------------------------------------------------------------------------------- 1 | //! Runs both signaling with server/client topology and runs the host in the same process 2 | //! 3 | //! Sends messages periodically to all connected clients. 4 | //! 5 | //! Note: When building a signaling server make sure you depend on 6 | //! `bevy_matchbox` with the `signaling` feature enabled. 7 | //! 8 | //! ```toml 9 | //! bevy_matchbox = { version = "0.x", features = ["signaling"] } 10 | //! ``` 11 | 12 | use bevy::{ 13 | app::ScheduleRunnerPlugin, log::LogPlugin, prelude::*, time::common_conditions::on_timer, 14 | }; 15 | use bevy_matchbox::{matchbox_signaling::SignalingServer, prelude::*}; 16 | use core::time::Duration; 17 | use std::net::{Ipv4Addr, SocketAddrV4}; 18 | 19 | fn main() { 20 | App::new() 21 | // .add_plugins(DefaultPlugins) 22 | .add_plugins(( 23 | MinimalPlugins.set(ScheduleRunnerPlugin::run_loop(Duration::from_secs_f32( 24 | 1. / 120., // be nice to the CPU 25 | ))), 26 | LogPlugin::default(), 27 | )) 28 | .add_systems(Startup, (start_signaling_server, start_host_socket).chain()) 29 | .add_systems(Update, receive_messages) 30 | .add_systems( 31 | Update, 32 | send_message.run_if(on_timer(Duration::from_secs(5))), 33 | ) 34 | .run(); 35 | } 36 | 37 | fn start_signaling_server(mut commands: Commands) { 38 | info!("Starting signaling server"); 39 | let addr = SocketAddrV4::new(Ipv4Addr::UNSPECIFIED, 3536); 40 | let signaling_server = MatchboxServer::from( 41 | SignalingServer::client_server_builder(addr) 42 | .on_connection_request(|connection| { 43 | info!("Connecting: {connection:?}"); 44 | Ok(true) // Allow all connections 45 | }) 46 | .on_id_assignment(|(socket, id)| info!("{socket} received {id}")) 47 | .on_host_connected(|id| info!("Host joined: {id}")) 48 | .on_host_disconnected(|id| info!("Host left: {id}")) 49 | .on_client_connected(|id| info!("Client joined: {id}")) 50 | .on_client_disconnected(|id| info!("Client left: {id}")) 51 | .cors() 52 | .trace() 53 | .build(), 54 | ); 55 | commands.insert_resource(signaling_server); 56 | } 57 | 58 | fn start_host_socket(mut commands: Commands) { 59 | let socket = MatchboxSocket::new_reliable("ws://localhost:3536/hello"); 60 | commands.insert_resource(socket); 61 | } 62 | 63 | fn send_message(mut socket: ResMut) { 64 | let peers: Vec<_> = socket.connected_peers().collect(); 65 | 66 | for peer in peers { 67 | let message = "Hello, I'm the host"; 68 | info!("Sending message: {message:?} to {peer}"); 69 | socket.channel_mut(0).send(message.as_bytes().into(), peer); 70 | } 71 | } 72 | 73 | fn receive_messages(mut socket: ResMut) { 74 | for (peer, state) in socket.update_peers() { 75 | info!("{peer}: {state:?}"); 76 | } 77 | 78 | for (_id, message) in socket.channel_mut(0).receive() { 79 | match std::str::from_utf8(&message) { 80 | Ok(message) => info!("Received message: {message:?}"), 81 | Err(e) => error!("Failed to convert message to string: {e}"), 82 | } 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /matchbox_signaling/src/topologies/mod.rs: -------------------------------------------------------------------------------- 1 | use crate::signaling_server::{ 2 | NoCallbacks, NoState, SignalingCallbacks, SignalingState, handlers::WsStateMeta, 3 | }; 4 | use async_trait::async_trait; 5 | use futures::{Future, future::BoxFuture}; 6 | use std::sync::Arc; 7 | 8 | /// An implementation of a client server topolgy 9 | pub mod client_server; 10 | /// An implementation of a full mesh topology 11 | pub mod full_mesh; 12 | 13 | #[derive(Clone)] 14 | pub(crate) struct SignalingStateMachine( 15 | #[allow(clippy::type_complexity)] 16 | pub Arc) -> BoxFuture<'static, ()> + Send + Sync>>, 17 | ); 18 | 19 | impl SignalingStateMachine 20 | where 21 | Cb: SignalingCallbacks, 22 | S: SignalingState, 23 | { 24 | pub(crate) fn from_topology(_: Topology) -> Self 25 | where 26 | Topology: SignalingTopology, 27 | { 28 | Self::new(|ws| >::state_machine(ws)) 29 | } 30 | 31 | pub(crate) fn new(callback: F) -> Self 32 | where 33 | F: Fn(WsStateMeta) -> Fut + 'static + Send + Sync, 34 | Fut: Future + 'static + Send, 35 | { 36 | Self(Arc::new(Box::new(move |ws| Box::pin(callback(ws))))) 37 | } 38 | } 39 | 40 | /// Topology produced by the signaling server 41 | #[async_trait] 42 | pub trait SignalingTopology 43 | where 44 | Cb: SignalingCallbacks, 45 | S: SignalingState, 46 | { 47 | /// A run-to-completion state machine, spawned once for every socket. 48 | async fn state_machine(upgrade: WsStateMeta); 49 | } 50 | 51 | /// Common, re-usable logic and types shared between topologies and which may be useful if building 52 | /// your own topology. 53 | pub mod common_logic { 54 | use crate::signaling_server::error::{ClientRequestError, SignalingError}; 55 | use axum::extract::ws::{Message, WebSocket}; 56 | use futures::{StreamExt, stream::SplitSink}; 57 | use matchbox_protocol::JsonPeerRequest; 58 | use std::{ 59 | str::FromStr, 60 | sync::{Arc, Mutex}, 61 | }; 62 | use tokio::sync::mpsc::{self, UnboundedSender}; 63 | use tokio_stream::wrappers::UnboundedReceiverStream; 64 | 65 | /// Alias for Arc> 66 | pub type StateObj = Arc>; 67 | 68 | /// Alias for UnboundedSender> 69 | pub type SignalingChannel = UnboundedSender>; 70 | 71 | /// Send a message to a channel without blocking. 72 | pub fn try_send(sender: &SignalingChannel, message: Message) -> Result<(), SignalingError> { 73 | sender.send(Ok(message)).map_err(SignalingError::from) 74 | } 75 | 76 | /// Helper to parse a request, currently we only support JSON text messages for signaling. 77 | pub fn parse_request( 78 | request: Result, 79 | ) -> Result { 80 | match request? { 81 | Message::Text(text) => Ok(JsonPeerRequest::from_str(&text)?), 82 | Message::Close(_) => Err(ClientRequestError::Close), 83 | m => Err(ClientRequestError::UnsupportedType(m)), 84 | } 85 | } 86 | 87 | /// Common helper method to spawn a sender 88 | pub fn spawn_sender_task( 89 | sender: SplitSink, 90 | ) -> mpsc::UnboundedSender> { 91 | let (client_sender, receiver) = mpsc::unbounded_channel(); 92 | tokio::task::spawn(UnboundedReceiverStream::new(receiver).forward(sender)); 93 | client_sender 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /examples/error_handling/src/main.rs: -------------------------------------------------------------------------------- 1 | use futures::{FutureExt, select}; 2 | use futures_timer::Delay; 3 | use log::{info, warn}; 4 | use matchbox_socket::{Error as SocketError, PeerId, PeerState, WebRtcSocket}; 5 | use std::time::Duration; 6 | 7 | const CHANNEL_ID: usize = 0; 8 | 9 | #[cfg(target_arch = "wasm32")] 10 | fn main() { 11 | // Setup logging 12 | console_error_panic_hook::set_once(); 13 | console_log::init_with_level(log::Level::Debug).unwrap(); 14 | 15 | wasm_bindgen_futures::spawn_local(async_main()); 16 | } 17 | 18 | #[cfg(not(target_arch = "wasm32"))] 19 | #[tokio::main] 20 | async fn main() { 21 | // Setup logging 22 | use tracing_subscriber::prelude::*; 23 | tracing_subscriber::registry() 24 | .with( 25 | tracing_subscriber::EnvFilter::try_from_default_env() 26 | .unwrap_or_else(|_| "error_handling_example=info,matchbox_socket=info".into()), 27 | ) 28 | .with(tracing_subscriber::fmt::layer()) 29 | .init(); 30 | 31 | async_main().await 32 | } 33 | 34 | async fn async_main() { 35 | info!("Connecting to matchbox"); 36 | let (mut socket, loop_fut) = 37 | WebRtcSocket::new_reliable("ws://localhost:3536/error_handling_example"); 38 | 39 | let loop_fut = async { 40 | match loop_fut.await { 41 | Ok(()) => info!("Exited cleanly :)"), 42 | Err(e) => match e { 43 | SocketError::ConnectionFailed(e) => { 44 | warn!("couldn't connect to signaling server, please check your connection: {e}"); 45 | // todo: show prompt and reconnect? 46 | } 47 | SocketError::Disconnected(e) => { 48 | warn!("you were kicked, or your connection went down, or the signaling server stopped: {e}"); 49 | } 50 | }, 51 | } 52 | } 53 | .fuse(); 54 | 55 | futures::pin_mut!(loop_fut); 56 | 57 | let timeout = Delay::new(Duration::from_millis(100)); 58 | futures::pin_mut!(timeout); 59 | 60 | let fake_user_quit = Delay::new(Duration::from_millis(20050)); // longer than reconnection timeouts 61 | futures::pin_mut!(fake_user_quit); 62 | 63 | loop { 64 | // Handle any new peers 65 | for (peer, state) in socket.update_peers() { 66 | match state { 67 | PeerState::Connected => { 68 | info!("Peer joined: {peer}"); 69 | let packet = "hello friend!".as_bytes().to_vec().into_boxed_slice(); 70 | socket.channel_mut(CHANNEL_ID).send(packet, peer); 71 | } 72 | PeerState::Disconnected => { 73 | info!("Peer left: {peer}"); 74 | } 75 | } 76 | } 77 | 78 | // Accept any messages incoming 79 | for (peer, packet) in socket.channel_mut(CHANNEL_ID).receive() { 80 | let message = String::from_utf8_lossy(&packet); 81 | info!("Message from {peer}: {message:?}"); 82 | } 83 | 84 | select! { 85 | // Restart this loop every 100ms 86 | _ = (&mut timeout).fuse() => { 87 | let peers: Vec = socket.connected_peers().collect(); 88 | for peer in peers { 89 | let packet = "ping!".as_bytes().to_vec().into_boxed_slice(); 90 | socket.channel_mut(CHANNEL_ID).send(packet, peer); 91 | } 92 | timeout.reset(Duration::from_millis(10)); 93 | } 94 | 95 | _ = (&mut fake_user_quit).fuse() => { 96 | info!("timeout, stopping sending/receiving"); 97 | break; 98 | } 99 | 100 | // Or break if the message loop ends (disconnected, closed, etc.) 101 | _ = &mut loop_fut => { 102 | break; 103 | } 104 | } 105 | } 106 | 107 | info!("dropping socket (intentionally disconnecting if connected)"); 108 | drop(socket); 109 | 110 | // join!(Delay::new(Duration::from_millis(2000)), loop_fut); 111 | 112 | Delay::new(Duration::from_millis(2000)).await; 113 | loop_fut.await; 114 | 115 | info!("Finished"); 116 | } 117 | -------------------------------------------------------------------------------- /matchbox_signaling/src/signaling_server/handlers.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | SignalingCallbacks, 3 | signaling_server::{SignalingState, callbacks::SharedCallbacks}, 4 | topologies::{ 5 | SignalingStateMachine, 6 | common_logic::{SignalingChannel, spawn_sender_task, try_send}, 7 | }, 8 | }; 9 | use axum::{ 10 | Extension, 11 | extract::{ 12 | ConnectInfo, Path, Query, WebSocketUpgrade, 13 | ws::{Message, WebSocket}, 14 | }, 15 | response::IntoResponse, 16 | }; 17 | use futures::{StreamExt, stream::SplitStream}; 18 | use hyper::{HeaderMap, StatusCode}; 19 | use matchbox_protocol::{JsonPeerEvent, PeerId}; 20 | use std::{collections::HashMap, net::SocketAddr}; 21 | use tracing::{error, info}; 22 | 23 | /// Metastate used during by a signaling server's runtime 24 | pub struct WsStateMeta { 25 | /// The room to associate the peer with 26 | pub room: String, 27 | /// The peer connecting, by their ID 28 | pub peer_id: PeerId, 29 | /// The channel to signal this peer through 30 | pub sender: SignalingChannel, 31 | /// The receiver to receive from this peer through 32 | pub receiver: SplitStream, 33 | /// Callbacks associated with the topology 34 | pub callbacks: Cb, 35 | /// State associated with the topology 36 | pub state: S, 37 | } 38 | 39 | /// Metadata captured at the time of websocket upgrade 40 | #[derive(Debug, Clone)] 41 | pub struct WsUpgradeMeta { 42 | pub origin: SocketAddr, 43 | pub path: Option, 44 | pub query_params: HashMap, 45 | pub headers: HeaderMap, 46 | } 47 | 48 | /// The handler for the HTTP request to upgrade to WebSockets. 49 | /// This is the last point where we can extract metadata such as IP address of the client. 50 | #[allow(clippy::too_many_arguments)] 51 | pub(crate) async fn ws_handler( 52 | ws: WebSocketUpgrade, 53 | path: Option>, 54 | headers: HeaderMap, 55 | Query(query_params): Query>, 56 | Extension(shared_callbacks): Extension, 57 | Extension(callbacks): Extension, 58 | Extension(state): Extension, 59 | Extension(state_machine): Extension>, 60 | ConnectInfo(origin): ConnectInfo, 61 | ) -> impl IntoResponse 62 | where 63 | Cb: SignalingCallbacks, 64 | S: SignalingState, 65 | { 66 | info!("`{origin}` connected."); 67 | 68 | let path = path.map(|path| path.0); 69 | 70 | // We will isolate peer connections by path, and if no path was specified 71 | // we will connect peers to a global room named 'world' 72 | let room = path.clone().unwrap_or("world".to_string()); 73 | 74 | let meta = WsUpgradeMeta { 75 | origin, 76 | path, 77 | query_params, 78 | headers, 79 | }; 80 | 81 | // Lifecycle event: On Connection Request 82 | match shared_callbacks.on_connection_request.emit(meta) { 83 | Ok(true) => {} 84 | Ok(false) => return (StatusCode::UNAUTHORIZED).into_response(), 85 | Err(e) => return e, 86 | }; 87 | 88 | // Finalize the upgrade process by returning upgrade callback to client 89 | // Generate an ID for the peer 90 | let peer_id = uuid::Uuid::new_v4().into(); 91 | 92 | // Lifecycle event: On ID Assignment 93 | shared_callbacks.on_id_assignment.emit((origin, peer_id)); 94 | 95 | ws.on_upgrade(move |ws| { 96 | let (ws_sink, receiver) = ws.split(); 97 | let sender = spawn_sender_task(ws_sink); 98 | 99 | // Send ID to peer 100 | let event_text = JsonPeerEvent::IdAssigned(peer_id).to_string(); 101 | let event = Message::Text((&event_text).into()); 102 | if let Err(e) = try_send(&sender, event) { 103 | error!("error sending to {peer_id}: {e:?}"); 104 | } else { 105 | info!("{peer_id} -> {event_text}"); 106 | }; 107 | 108 | info!("`{peer_id}` will be isolated to room '{room}'."); 109 | 110 | let meta = WsStateMeta { 111 | room, 112 | peer_id, 113 | sender, 114 | receiver, 115 | callbacks, 116 | state, 117 | }; 118 | (*state_machine.0)(meta) 119 | }) 120 | } 121 | -------------------------------------------------------------------------------- /examples/bevy_ggrs/assets/fonts/quicksand-light.txt: -------------------------------------------------------------------------------- 1 | Copyright 2011 The Quicksand Project Authors (https://github.com/andrew-paglinawan/QuicksandFamily), with Reserved Font Name “Quicksand”. 2 | 3 | This Font Software is licensed under the SIL Open Font License, Version 1.1. 4 | This license is copied below, and is also available with a FAQ at: 5 | http://scripts.sil.org/OFL 6 | 7 | 8 | ----------------------------------------------------------- 9 | SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 10 | ----------------------------------------------------------- 11 | 12 | PREAMBLE 13 | The goals of the Open Font License (OFL) are to stimulate worldwide 14 | development of collaborative font projects, to support the font creation 15 | efforts of academic and linguistic communities, and to provide a free and 16 | open framework in which fonts may be shared and improved in partnership 17 | with others. 18 | 19 | The OFL allows the licensed fonts to be used, studied, modified and 20 | redistributed freely as long as they are not sold by themselves. The 21 | fonts, including any derivative works, can be bundled, embedded, 22 | redistributed and/or sold with any software provided that any reserved 23 | names are not used by derivative works. The fonts and derivatives, 24 | however, cannot be released under any other type of license. The 25 | requirement for fonts to remain under this license does not apply 26 | to any document created using the fonts or their derivatives. 27 | 28 | DEFINITIONS 29 | "Font Software" refers to the set of files released by the Copyright 30 | Holder(s) under this license and clearly marked as such. This may 31 | include source files, build scripts and documentation. 32 | 33 | "Reserved Font Name" refers to any names specified as such after the 34 | copyright statement(s). 35 | 36 | "Original Version" refers to the collection of Font Software components as 37 | distributed by the Copyright Holder(s). 38 | 39 | "Modified Version" refers to any derivative made by adding to, deleting, 40 | or substituting -- in part or in whole -- any of the components of the 41 | Original Version, by changing formats or by porting the Font Software to a 42 | new environment. 43 | 44 | "Author" refers to any designer, engineer, programmer, technical 45 | writer or other person who contributed to the Font Software. 46 | 47 | PERMISSION & CONDITIONS 48 | Permission is hereby granted, free of charge, to any person obtaining 49 | a copy of the Font Software, to use, study, copy, merge, embed, modify, 50 | redistribute, and sell modified and unmodified copies of the Font 51 | Software, subject to the following conditions: 52 | 53 | 1) Neither the Font Software nor any of its individual components, 54 | in Original or Modified Versions, may be sold by itself. 55 | 56 | 2) Original or Modified Versions of the Font Software may be bundled, 57 | redistributed and/or sold with any software, provided that each copy 58 | contains the above copyright notice and this license. These can be 59 | included either as stand-alone text files, human-readable headers or 60 | in the appropriate machine-readable metadata fields within text or 61 | binary files as long as those fields can be easily viewed by the user. 62 | 63 | 3) No Modified Version of the Font Software may use the Reserved Font 64 | Name(s) unless explicit written permission is granted by the corresponding 65 | Copyright Holder. This restriction only applies to the primary font name as 66 | presented to the users. 67 | 68 | 4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font 69 | Software shall not be used to promote, endorse or advertise any 70 | Modified Version, except to acknowledge the contribution(s) of the 71 | Copyright Holder(s) and the Author(s) or with their explicit written 72 | permission. 73 | 74 | 5) The Font Software, modified or unmodified, in part or in whole, 75 | must be distributed entirely under this license, and must not be 76 | distributed under any other license. The requirement for fonts to 77 | remain under this license does not apply to any document created 78 | using the Font Software. 79 | 80 | TERMINATION 81 | This license becomes null and void if any of the above conditions are 82 | not met. 83 | 84 | DISCLAIMER 85 | THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 86 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF 87 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT 88 | OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE 89 | COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 90 | INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL 91 | DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 92 | FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM 93 | OTHER DEALINGS IN THE FONT SOFTWARE. 94 | -------------------------------------------------------------------------------- /examples/custom_signaller/src/main.rs: -------------------------------------------------------------------------------- 1 | use custom_signaller::IrohGossipSignallerBuilder; 2 | use futures::{select, FutureExt}; 3 | use futures_timer::Delay; 4 | use matchbox_socket::{PeerState, WebRtcSocket}; 5 | use std::{sync::Arc, time::Duration}; 6 | use tracing::info; 7 | 8 | const CHANNEL_ID: usize = 0; 9 | const LOG_FILTER: &str = "info,custom_signaller=info,iroh=error"; 10 | 11 | #[cfg(target_arch = "wasm32")] 12 | fn main() { 13 | // see https://github.com/DioxusLabs/dioxus/issues/3774#issuecomment-2733307383 14 | use tracing::{subscriber::set_global_default, Level}; 15 | use tracing_subscriber::{layer::SubscriberExt, Registry}; 16 | 17 | let layer_config = tracing_wasm::WASMLayerConfigBuilder::new() 18 | .set_max_level(Level::INFO) 19 | .build(); 20 | let layer = tracing_wasm::WASMLayer::new(layer_config); 21 | let filter: tracing_subscriber::EnvFilter = LOG_FILTER.parse().unwrap(); 22 | 23 | let event_format = tracing_subscriber::fmt::format() 24 | .with_level(false) // don't include levels in formatted output 25 | .with_target(false) // don't include targets 26 | .with_thread_ids(false) // include the thread ID of the current thread 27 | .with_thread_names(false) // include the name of the current thread 28 | .with_line_number(false) 29 | .with_source_location(false) 30 | .with_file(false) 31 | .with_ansi(false) 32 | .compact() 33 | .without_time(); // use the `Compact` formatting style. 34 | 35 | let reg = Registry::default().with(layer).with(filter).with( 36 | tracing_subscriber::fmt::layer() 37 | .without_time() 38 | .event_format(event_format), 39 | ); 40 | 41 | console_error_panic_hook::set_once(); 42 | let _ = set_global_default(reg); 43 | 44 | use custom_signaller::get_browser_url::get_browser_url_hash; 45 | wasm_bindgen_futures::spawn_local(async_main(get_browser_url_hash())); 46 | } 47 | 48 | #[cfg(not(target_arch = "wasm32"))] 49 | #[tokio::main] 50 | async fn main() { 51 | // Setup logging 52 | use tracing_subscriber::prelude::*; 53 | tracing_subscriber::registry() 54 | .with( 55 | tracing_subscriber::EnvFilter::try_from_default_env() 56 | .unwrap_or_else(|_| LOG_FILTER.into()), 57 | ) 58 | .with(tracing_subscriber::fmt::layer()) 59 | .init(); 60 | 61 | async_main(std::env::args().nth(1)).await 62 | } 63 | 64 | async fn async_main(node_id: Option) { 65 | info!("Connecting to matchbox, room url: {:?}", node_id); 66 | let room_url = node_id.unwrap_or("".to_string()); 67 | let signaller_builder = Arc::new(IrohGossipSignallerBuilder::new().await.unwrap()); 68 | let (mut socket, loop_fut) = WebRtcSocket::builder(room_url) 69 | .signaller_builder(signaller_builder.clone()) 70 | .add_reliable_channel() 71 | .build(); 72 | 73 | let loop_fut = loop_fut.fuse(); 74 | futures::pin_mut!(loop_fut); 75 | 76 | let timeout = Delay::new(Duration::from_millis(100)); 77 | futures::pin_mut!(timeout); 78 | 79 | loop { 80 | // Handle any new peers 81 | for (peer, state) in socket.update_peers() { 82 | match state { 83 | PeerState::Connected => { 84 | info!("Peer joined: {peer}"); 85 | let packet = "hello friend!".as_bytes().to_vec().into_boxed_slice(); 86 | socket.channel_mut(CHANNEL_ID).send(packet, peer); 87 | 88 | let packet = format!("ping {}", custom_signaller::get_timestamp()) 89 | .as_bytes() 90 | .to_vec() 91 | .into_boxed_slice(); 92 | socket.channel_mut(CHANNEL_ID).send(packet, peer); 93 | } 94 | PeerState::Disconnected => { 95 | info!("Peer left: {peer}"); 96 | } 97 | } 98 | } 99 | 100 | // Accept any messages incoming 101 | for (peer, packet) in socket.channel_mut(CHANNEL_ID).receive() { 102 | let message = String::from_utf8_lossy(&packet); 103 | if message.contains("ping") { 104 | let ts = message.split(" ").nth(1).unwrap().parse::().unwrap(); 105 | let packet = format!("pong {ts}").as_bytes().to_vec().into_boxed_slice(); 106 | socket.channel_mut(CHANNEL_ID).send(packet, peer); 107 | } 108 | if message.contains("pong") { 109 | let ts = message.split(" ").nth(1).unwrap().parse::().unwrap(); 110 | let now = custom_signaller::get_timestamp(); 111 | let diff = now - ts; 112 | info!("\n\n\t peer {peer} ping: {}ms\n\n", diff / 1000); 113 | } else { 114 | info!("Message from {peer}: \n\n {message:?} \n"); 115 | } 116 | } 117 | 118 | select! { 119 | // Restart this loop every 100ms 120 | _ = (&mut timeout).fuse() => { 121 | timeout.reset(Duration::from_millis(100)); 122 | } 123 | 124 | // Or break if the message loop ends (disconnected, closed, etc.) 125 | _ = &mut loop_fut => { 126 | break; 127 | } 128 | } 129 | } 130 | signaller_builder.shutdown().await.unwrap(); 131 | } 132 | -------------------------------------------------------------------------------- /matchbox_signaling/src/signaling_server/builder.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | SignalingCallbacks, SignalingServer, SignalingState, 3 | signaling_server::{ 4 | NoCallbacks, NoState, 5 | callbacks::{Callback, SharedCallbacks}, 6 | handlers::{WsUpgradeMeta, ws_handler}, 7 | }, 8 | topologies::{SignalingStateMachine, SignalingTopology}, 9 | }; 10 | use axum::{Extension, Router, response::Response, routing::get}; 11 | use matchbox_protocol::PeerId; 12 | use std::{convert::identity, net::SocketAddr}; 13 | use tower_http::{ 14 | LatencyUnit, 15 | cors::{Any, CorsLayer}, 16 | trace::{DefaultOnResponse, TraceLayer}, 17 | }; 18 | use tracing::Level; 19 | 20 | /// Builder for [`SignalingServer`]s. 21 | /// 22 | /// Begin with [`SignalingServerBuilder::new`] and add parameters before calling 23 | /// [`SignalingServerBuilder::build`] to produce the desired [`SignalingServer`]. 24 | pub struct SignalingServerBuilder 25 | where 26 | Topology: SignalingTopology, 27 | Cb: SignalingCallbacks, 28 | S: SignalingState, 29 | { 30 | /// The socket address to broadcast on 31 | pub(crate) socket_addr: SocketAddr, 32 | 33 | /// The router used by the signaling server 34 | pub(crate) router: Router, 35 | 36 | /// Shared callouts used by all signaling servers 37 | pub(crate) shared_callbacks: SharedCallbacks, 38 | 39 | /// The callbacks used by the signaling server 40 | pub(crate) callbacks: Cb, 41 | 42 | /// The state machine that runs a websocket to completion, also where topology is implemented 43 | pub(crate) topology: Topology, 44 | 45 | /// Arbitrary state accompanying a server 46 | pub(crate) state: S, 47 | } 48 | 49 | impl SignalingServerBuilder 50 | where 51 | Topology: SignalingTopology, 52 | Cb: SignalingCallbacks, 53 | S: SignalingState, 54 | { 55 | /// Creates a new builder for a [`SignalingServer`]. 56 | pub fn new(socket_addr: impl Into, topology: Topology, state: S) -> Self { 57 | Self { 58 | socket_addr: socket_addr.into(), 59 | router: Router::new(), 60 | shared_callbacks: SharedCallbacks::default(), 61 | callbacks: Cb::default(), 62 | topology, 63 | state, 64 | } 65 | } 66 | 67 | /// Modify the router with a mutable closure. This is where one may apply middleware or other 68 | /// layers to the Router. 69 | pub fn mutate_router(mut self, alter: impl FnOnce(Router) -> Router) -> Self { 70 | self.router = alter(self.router); 71 | self 72 | } 73 | 74 | /// Set a callback triggered before websocket upgrade to determine if the connection is allowed. 75 | pub fn on_connection_request(mut self, callback: F) -> Self 76 | where 77 | F: FnMut(WsUpgradeMeta) -> Result + Send + Sync + 'static, 78 | { 79 | self.shared_callbacks.on_connection_request = Callback::from(callback); 80 | self 81 | } 82 | 83 | /// Set a callback triggered when a socket has been assigned an ID. This happens after a 84 | /// connection is allowed, right before finalizing the websocket upgrade. 85 | pub fn on_id_assignment(mut self, callback: F) -> Self 86 | where 87 | F: FnMut((SocketAddr, PeerId)) + Send + Sync + 'static, 88 | { 89 | self.shared_callbacks.on_id_assignment = Callback::from(callback); 90 | self 91 | } 92 | 93 | /// Apply permissive CORS middleware for debug purposes. 94 | pub fn cors(mut self) -> Self { 95 | self.router = self.router.layer( 96 | CorsLayer::new() 97 | .allow_origin(Any) 98 | .allow_methods(Any) 99 | .allow_headers(Any), 100 | ); 101 | self 102 | } 103 | 104 | /// Apply a default tracing middleware layer for debug purposes. 105 | pub fn trace(mut self) -> Self { 106 | self.router = self.router.layer( 107 | // Middleware for logging from tower-http 108 | TraceLayer::new_for_http().on_response( 109 | DefaultOnResponse::new() 110 | .level(Level::INFO) 111 | .latency_unit(LatencyUnit::Micros), 112 | ), 113 | ); 114 | self 115 | } 116 | 117 | /// Create a [`SignalingServer`]. 118 | pub fn build(self) -> SignalingServer { 119 | self.build_with(identity) 120 | } 121 | 122 | /// Create a [`SignalingServer`] with a closure that modifies the signaling router 123 | pub fn build_with(self, alter: impl FnOnce(Router) -> Router) -> SignalingServer { 124 | // Insert topology 125 | let state_machine: SignalingStateMachine = 126 | SignalingStateMachine::from_topology(self.topology); 127 | let info = alter( 128 | self.router 129 | .route("/", get(ws_handler::)) 130 | .route("/{path}", get(ws_handler::)) 131 | .layer(Extension(state_machine)) 132 | .layer(Extension(self.shared_callbacks)) 133 | .layer(Extension(self.callbacks)) 134 | .layer(Extension(self.state)), 135 | ) 136 | .into_make_service_with_connect_info::(); 137 | SignalingServer { 138 | requested_addr: self.socket_addr, 139 | info, 140 | listener: None, 141 | } 142 | } 143 | } 144 | -------------------------------------------------------------------------------- /.github/workflows/code.yml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | branches: 4 | - main 5 | pull_request: 6 | 7 | name: Code 8 | 9 | jobs: 10 | test-native: 11 | name: Test Native 12 | strategy: 13 | matrix: 14 | os: [ubuntu-latest, windows-latest, macos-latest] 15 | runs-on: ${{ matrix.os }} 16 | steps: 17 | - name: Checkout sources 18 | uses: actions/checkout@v6 19 | 20 | - name: Install stable toolchain 21 | uses: dtolnay/rust-toolchain@stable 22 | with: 23 | toolchain: stable 24 | 25 | - name: Rust Cache 26 | uses: Swatinem/rust-cache@v2 27 | 28 | - name: Run cargo test 29 | run: cargo test --features signaling --all-targets 30 | 31 | - name: Run cargo doc tests 32 | run: cargo test --doc 33 | 34 | # Should be upgraded to test when possible 35 | check-wasm: 36 | name: Check Wasm 37 | runs-on: ubuntu-latest 38 | steps: 39 | - name: Checkout sources 40 | uses: actions/checkout@v6 41 | 42 | - name: Install stable toolchain 43 | uses: dtolnay/rust-toolchain@stable 44 | with: 45 | toolchain: stable 46 | target: wasm32-unknown-unknown 47 | 48 | - name: Rust Cache 49 | uses: Swatinem/rust-cache@v2 50 | 51 | - name: Run cargo check 52 | # getrandom_backend="wasm_js" is needed for tracing to compile 53 | run: RUSTFLAGS='--cfg=web_sys_unstable_apis --cfg=getrandom_backend="wasm_js"' cargo check > 54 | --all-targets 55 | --target wasm32-unknown-unknown 56 | -p matchbox_socket 57 | -p bevy_matchbox 58 | -p bevy_ggrs_example 59 | -p simple_example 60 | -p custom_signaller 61 | 62 | format: 63 | name: Format 64 | runs-on: ubuntu-latest 65 | steps: 66 | - name: Checkout sources 67 | uses: actions/checkout@v6 68 | 69 | - name: Install stable toolchain 70 | uses: dtolnay/rust-toolchain@stable 71 | with: 72 | toolchain: stable 73 | components: rustfmt 74 | 75 | - name: Rust Cache 76 | uses: Swatinem/rust-cache@v2 77 | 78 | - name: Run cargo clippy 79 | run: cargo fmt --all --check 80 | 81 | 82 | lint-native: 83 | name: Lints native 84 | strategy: 85 | matrix: 86 | os: [ubuntu-latest, windows-latest, macos-latest] 87 | runs-on: ${{ matrix.os }} 88 | steps: 89 | - name: Checkout sources 90 | uses: actions/checkout@v6 91 | 92 | - name: Install stable toolchain 93 | uses: dtolnay/rust-toolchain@stable 94 | with: 95 | toolchain: stable 96 | components: clippy 97 | 98 | - name: Rust Cache 99 | uses: Swatinem/rust-cache@v2 100 | 101 | - name: Run cargo clippy 102 | run: cargo clippy --features signaling --all-targets -- -D warnings 103 | 104 | lint-wasm: 105 | name: Clippy wasm 106 | runs-on: ubuntu-latest 107 | steps: 108 | - name: Checkout sources 109 | uses: actions/checkout@v6 110 | 111 | - name: Install stable toolchain 112 | uses: dtolnay/rust-toolchain@stable 113 | with: 114 | toolchain: stable 115 | target: wasm32-unknown-unknown 116 | components: clippy 117 | 118 | - name: Rust Cache 119 | uses: Swatinem/rust-cache@v2 120 | 121 | - name: Run cargo clippy 122 | run: RUSTFLAGS='--cfg=web_sys_unstable_apis --cfg=getrandom_backend="wasm_js"' cargo clippy > 123 | --all-targets 124 | --target wasm32-unknown-unknown 125 | -p matchbox_socket 126 | -p bevy_matchbox 127 | -p bevy_ggrs_example 128 | -p simple_example 129 | -p custom_signaller 130 | -- 131 | -D warnings 132 | 133 | server-container: 134 | name: Build & Push Server Container 135 | needs: [test-native, check-wasm, lint-native, lint-wasm] 136 | runs-on: ubuntu-latest 137 | permissions: 138 | packages: write 139 | steps: 140 | - name: Checkout Repository 141 | uses: actions/checkout@v6 142 | 143 | - name: Generate Image Name 144 | run: echo IMAGE_REPOSITORY=ghcr.io/$(tr '[:upper:]' '[:lower:]' <<< "${{ github.repository }}")/matchbox_server >> $GITHUB_ENV 145 | 146 | - name: Log in to GitHub Docker Registry 147 | if: github.event_name != 'pull_request' 148 | uses: docker/login-action@v3 149 | with: 150 | registry: ghcr.io 151 | username: ${{ github.repository_owner }} 152 | password: ${{ secrets.GITHUB_TOKEN }} 153 | 154 | - name: Docker Metadata 155 | id: meta 156 | uses: docker/metadata-action@v5 157 | with: 158 | images: ${{ env.IMAGE_REPOSITORY }} 159 | tags: | 160 | type=ref,event=tag 161 | type=raw,value=latest 162 | 163 | - name: Set up Docker Buildx 164 | uses: docker/setup-buildx-action@v3 165 | 166 | - name: Build Image 167 | uses: docker/build-push-action@v6 168 | with: 169 | context: "." 170 | file: "matchbox_server/Dockerfile" 171 | push: ${{ github.event_name == 'push' && startsWith(github.ref, 'refs/tags') }} 172 | load: ${{ ! (github.event_name == 'push' && startsWith(github.ref, 'refs/tags')) }} 173 | tags: ${{ steps.meta.outputs.tags }} 174 | labels: ${{ steps.meta.outputs.labels }} 175 | cache-from: type=gha 176 | cache-to: type=gha,mode=max 177 | -------------------------------------------------------------------------------- /matchbox_server/src/state.rs: -------------------------------------------------------------------------------- 1 | use axum::{Error, extract::ws::Message}; 2 | use matchbox_protocol::PeerId; 3 | use matchbox_signaling::{ 4 | SignalingError, SignalingState, 5 | common_logic::{self, StateObj}, 6 | }; 7 | use serde::Deserialize; 8 | use std::{ 9 | collections::{HashMap, HashSet}, 10 | net::SocketAddr, 11 | }; 12 | use tokio::sync::mpsc::UnboundedSender; 13 | 14 | #[derive(Debug, Deserialize, Default, Clone, PartialEq, Eq, Hash)] 15 | pub(crate) struct RoomId(pub String); 16 | 17 | #[derive(Debug, Clone, PartialEq, Eq, Hash)] 18 | pub(crate) struct RequestedRoom { 19 | pub id: RoomId, 20 | pub next: Option, 21 | } 22 | 23 | #[derive(Debug, Clone)] 24 | pub(crate) struct Peer { 25 | pub uuid: PeerId, 26 | pub room: RequestedRoom, 27 | pub sender: UnboundedSender>, 28 | } 29 | 30 | #[derive(Default, Debug, Clone)] 31 | pub(crate) struct ServerState { 32 | clients_waiting: StateObj>, 33 | clients_in_queue: StateObj>, 34 | clients: StateObj>, 35 | rooms: StateObj>>, 36 | matched_by_next: StateObj>>, 37 | } 38 | impl SignalingState for ServerState {} 39 | 40 | impl ServerState { 41 | /// Add a waiting client to matchmaking 42 | pub fn add_waiting_client(&mut self, origin: SocketAddr, room: RequestedRoom) { 43 | self.clients_waiting.lock().unwrap().insert(origin, room); 44 | } 45 | 46 | /// Assign a peer id to a waiting client 47 | pub fn assign_id_to_waiting_client(&mut self, origin: SocketAddr, peer_id: PeerId) { 48 | let room = { 49 | let mut lock = self.clients_waiting.lock().unwrap(); 50 | lock.remove(&origin).expect("waiting client") 51 | }; 52 | { 53 | let mut lock = self.clients_in_queue.lock().unwrap(); 54 | lock.insert(peer_id, room); 55 | } 56 | } 57 | 58 | /// Remove the waiting peer, returning the peer's requested room 59 | pub fn remove_waiting_peer(&mut self, peer_id: PeerId) -> RequestedRoom { 60 | { 61 | let mut lock = self.clients_in_queue.lock().unwrap(); 62 | lock.remove(&peer_id).expect("waiting peer") 63 | } 64 | } 65 | 66 | /// Add a peer, returning the peers already in room 67 | pub fn add_peer(&mut self, peer: Peer) -> Vec { 68 | let peer_id = peer.uuid; 69 | let room = peer.room.clone(); 70 | { 71 | let mut clients = self.clients.lock().unwrap(); 72 | clients.insert(peer.uuid, peer); 73 | }; 74 | let mut rooms = self.rooms.lock().unwrap(); 75 | let peers = rooms.entry(room.clone()).or_default(); 76 | let prev_peers = peers.iter().cloned().collect(); 77 | 78 | match room.next { 79 | None => { 80 | peers.insert(peer_id); 81 | } 82 | Some(num_players) => { 83 | if peers.len() == num_players - 1 { 84 | let mut matched_by_next = self.matched_by_next.lock().unwrap(); 85 | let mut updated_peers = peers.clone(); 86 | updated_peers.insert(peer_id); 87 | matched_by_next.insert(updated_peers.into_iter().collect()); 88 | 89 | peers.clear(); // room is complete 90 | } else { 91 | peers.insert(peer_id); 92 | } 93 | } 94 | }; 95 | 96 | prev_peers 97 | } 98 | 99 | pub fn remove_matched_peer(&mut self, peer: PeerId) -> Vec { 100 | let mut matched_by_next = self.matched_by_next.lock().unwrap(); 101 | let mut peers = vec![]; 102 | matched_by_next.retain(|group| { 103 | if group.contains(&peer) { 104 | peers = group.clone(); 105 | return false; 106 | } 107 | 108 | true 109 | }); 110 | 111 | peers.retain(|p| p != &peer); 112 | 113 | if !peers.is_empty() { 114 | matched_by_next.insert(peers.clone()); 115 | } 116 | 117 | peers 118 | } 119 | 120 | /// Get a peer 121 | pub fn get_peer(&self, peer_id: &PeerId) -> Option { 122 | let clients = self.clients.lock().unwrap(); 123 | clients.get(peer_id).cloned() 124 | } 125 | 126 | /// Get the peers in a room currently 127 | pub fn get_room_peers(&self, room: &RequestedRoom) -> Vec { 128 | self.rooms 129 | .lock() 130 | .unwrap() 131 | .get(room) 132 | .map(|room_peers| room_peers.iter().copied().collect::>()) 133 | .unwrap_or_default() 134 | } 135 | 136 | /// Remove a peer from the state if it existed, returning the peer removed. 137 | #[must_use] 138 | pub fn remove_peer(&mut self, peer_id: &PeerId) -> Option { 139 | let peer = { self.clients.lock().unwrap().remove(peer_id) }; 140 | 141 | if let Some(ref peer) = peer { 142 | // Best effort to remove peer from their room 143 | _ = self 144 | .rooms 145 | .lock() 146 | .unwrap() 147 | .get_mut(&peer.room) 148 | .map(|room| room.remove(peer_id)); 149 | } 150 | peer 151 | } 152 | 153 | /// Send a message to a peer without blocking. 154 | pub fn try_send(&self, id: PeerId, message: Message) -> Result<(), SignalingError> { 155 | let clients = self.clients.lock().unwrap(); 156 | match clients.get(&id) { 157 | Some(peer) => Ok(common_logic::try_send(&peer.sender, message)?), 158 | None => Err(SignalingError::UnknownPeer), 159 | } 160 | } 161 | } 162 | -------------------------------------------------------------------------------- /examples/async_example/src/main.rs: -------------------------------------------------------------------------------- 1 | use futures::{FutureExt, SinkExt, StreamExt}; 2 | use matchbox_socket::{Packet, PeerId, PeerState, WebRtcSocket}; 3 | use n0_future::task::{AbortOnDropHandle, spawn}; 4 | use std::{collections::BTreeMap, sync::Arc, time::Duration}; 5 | use tokio::sync::{ 6 | RwLock, 7 | mpsc::{Receiver, Sender, channel}, 8 | }; 9 | use tracing::{info, warn}; 10 | 11 | const CHANNEL_ID: usize = 0; 12 | 13 | fn get_timestamp() -> u128 { 14 | web_time::SystemTime::now() 15 | .duration_since(web_time::UNIX_EPOCH) 16 | .unwrap() 17 | .as_micros() 18 | } 19 | 20 | #[cfg(target_arch = "wasm32")] 21 | fn main() { 22 | // Setup logging 23 | console_error_panic_hook::set_once(); 24 | console_log::init_with_level(log::Level::Debug).unwrap(); 25 | 26 | wasm_bindgen_futures::spawn_local(async_main()); 27 | } 28 | 29 | #[cfg(not(target_arch = "wasm32"))] 30 | #[tokio::main] 31 | async fn main() { 32 | // Setup logging 33 | use tracing_subscriber::prelude::*; 34 | tracing_subscriber::registry() 35 | .with( 36 | tracing_subscriber::EnvFilter::try_from_default_env() 37 | .unwrap_or_else(|_| "async_example=info,matchbox_socket=info".into()), 38 | ) 39 | .with(tracing_subscriber::fmt::layer()) 40 | .init(); 41 | 42 | async_main().await 43 | } 44 | 45 | async fn async_main() { 46 | let (mut socket, loop_fut) = WebRtcSocket::new_reliable("ws://localhost:3536/"); 47 | 48 | let loop_fut = loop_fut.fuse(); 49 | futures::pin_mut!(loop_fut); 50 | 51 | let (tx0, rx0) = socket.take_channel(CHANNEL_ID).unwrap().split(); 52 | 53 | #[allow(clippy::type_complexity)] 54 | let tasks: Arc< 55 | RwLock< 56 | BTreeMap< 57 | PeerId, 58 | ( 59 | (AbortOnDropHandle<()>, AbortOnDropHandle<()>), 60 | Sender>, 61 | ), 62 | >, 63 | >, 64 | > = Arc::new(RwLock::new(BTreeMap::new())); 65 | let tasks_ = tasks.clone(); 66 | let _task_rx_route = spawn(async move { 67 | futures::pin_mut!(rx0); 68 | while let Some((peer, packet)) = rx0.next().await { 69 | let tx = { 70 | let r = tasks_.read().await; 71 | let Some((_, tx)) = r.get(&peer) else { 72 | warn!("Received packet from unknown peer: {peer}"); 73 | continue; 74 | }; 75 | tx.clone() 76 | }; 77 | tx.send(packet).await.unwrap(); 78 | } 79 | }); 80 | 81 | let tasks_ = tasks.clone(); 82 | let _dispatch_task = AbortOnDropHandle::new(spawn(async move { 83 | // Handle any new peers 84 | while let Some((peer, state)) = socket.next().await { 85 | let mut tx0 = tx0.clone(); 86 | match state { 87 | PeerState::Connected => { 88 | info!("Peer joined: {peer}"); 89 | let (rx_sender, rx) = channel(1); 90 | let (tx, mut tx_recv) = channel(1); 91 | let _task_tx_combine = AbortOnDropHandle::new(spawn(async move { 92 | while let Some(packet) = tx_recv.recv().await { 93 | tx0.send((peer, packet)).await.unwrap(); 94 | } 95 | })); 96 | let task = AbortOnDropHandle::new(spawn(socket_task(peer, tx, rx))); 97 | { 98 | tasks_ 99 | .write() 100 | .await 101 | .insert(peer, ((task, _task_tx_combine), rx_sender)); 102 | } 103 | } 104 | PeerState::Disconnected => { 105 | info!("Peer left: {peer}"); 106 | { 107 | tasks_.write().await.remove(&peer); 108 | } 109 | } 110 | } 111 | } 112 | })); 113 | 114 | let _ = loop_fut.await; 115 | tasks.write().await.clear(); 116 | } 117 | 118 | async fn socket_task(peer: PeerId, tx: Sender, mut rx: Receiver) { 119 | let writer = tx.clone(); 120 | let ping_task = spawn(async move { 121 | for _i in 0..20 { 122 | n0_future::time::sleep(Duration::from_secs_f32(0.25)).await; 123 | if _i == 0 { 124 | writer.send(b"hello friend!".to_vec().into()).await.unwrap(); 125 | } 126 | writer 127 | .send( 128 | format!("ping {}", get_timestamp()) 129 | .as_bytes() 130 | .to_vec() 131 | .into(), 132 | ) 133 | .await 134 | .unwrap(); 135 | } 136 | }); 137 | let writer = tx.clone(); 138 | let pong_task = spawn(async move { 139 | while let Some(packet) = rx.recv().await { 140 | let message = String::from_utf8_lossy(&packet); 141 | if message.starts_with("ping") { 142 | let ts = message.split(" ").nth(1).unwrap().parse::().unwrap(); 143 | let packet = format!("pong {ts}").as_bytes().to_vec(); 144 | writer.send(packet.into()).await.unwrap(); 145 | } else if message.starts_with("pong") { 146 | let ts = message.split(" ").nth(1).unwrap().parse::().unwrap(); 147 | let now = get_timestamp(); 148 | let diff = now - ts; 149 | info!("Ping from {peer} took {}ms", diff as f32 / 1000.0); 150 | } else { 151 | info!("Message from {peer}: \n\n {message:?} \n"); 152 | } 153 | } 154 | }); 155 | ping_task.await.unwrap(); 156 | pong_task.await.unwrap(); 157 | } 158 | -------------------------------------------------------------------------------- /bevy_matchbox/src/socket.rs: -------------------------------------------------------------------------------- 1 | use bevy::{ 2 | prelude::{Command, Commands, Component, Resource, World}, 3 | tasks::IoTaskPool, 4 | }; 5 | pub use matchbox_socket; 6 | use matchbox_socket::{MessageLoopFuture, WebRtcSocket, WebRtcSocketBuilder}; 7 | use std::{ 8 | fmt::Debug, 9 | ops::{Deref, DerefMut}, 10 | }; 11 | 12 | /// A [`WebRtcSocket`] as a [`Component`] or [`Resource`]. 13 | /// 14 | /// As a [`Component`], directly 15 | /// ``` 16 | /// use bevy_matchbox::prelude::*; 17 | /// use bevy::prelude::*; 18 | /// 19 | /// fn open_socket_system(mut commands: Commands) { 20 | /// let room_url = "wss://matchbox.example.com"; 21 | /// let builder = WebRtcSocketBuilder::new(room_url).add_channel(ChannelConfig::reliable()); 22 | /// commands.spawn(MatchboxSocket::from(builder)); 23 | /// } 24 | /// 25 | /// fn close_socket_system( 26 | /// mut commands: Commands, 27 | /// socket: Single> 28 | /// ) { 29 | /// let socket = socket.into_inner(); 30 | /// commands.entity(socket).despawn(); 31 | /// } 32 | /// ``` 33 | /// 34 | /// As a [`Resource`], with [`Commands`] 35 | /// ``` 36 | /// use bevy_matchbox::prelude::*; 37 | /// use bevy::prelude::*; 38 | /// 39 | /// fn open_socket_system(mut commands: Commands) { 40 | /// let room_url = "wss://matchbox.example.com"; 41 | /// commands.open_socket(WebRtcSocketBuilder::new(room_url).add_channel(ChannelConfig::reliable())); 42 | /// } 43 | /// 44 | /// fn close_socket_system(mut commands: Commands) { 45 | /// commands.close_socket(); 46 | /// } 47 | /// ``` 48 | /// 49 | /// As a [`Resource`], directly 50 | /// ``` 51 | /// use bevy_matchbox::prelude::*; 52 | /// use bevy::prelude::*; 53 | /// 54 | /// fn open_socket_system(mut commands: Commands) { 55 | /// let room_url = "wss://matchbox.example.com"; 56 | /// 57 | /// let socket: MatchboxSocket = WebRtcSocketBuilder::new(room_url) 58 | /// .add_channel(ChannelConfig::reliable()) 59 | /// .into(); 60 | /// 61 | /// commands.insert_resource(socket); 62 | /// } 63 | /// 64 | /// fn close_socket_system(mut commands: Commands) { 65 | /// commands.remove_resource::(); 66 | /// } 67 | /// ``` 68 | #[derive(Resource, Component, Debug)] 69 | #[allow(dead_code)] // keep the task alive so it doesn't drop before the socket 70 | pub struct MatchboxSocket(WebRtcSocket, Box); 71 | 72 | impl Deref for MatchboxSocket { 73 | type Target = WebRtcSocket; 74 | 75 | fn deref(&self) -> &Self::Target { 76 | &self.0 77 | } 78 | } 79 | 80 | impl DerefMut for MatchboxSocket { 81 | fn deref_mut(&mut self) -> &mut Self::Target { 82 | &mut self.0 83 | } 84 | } 85 | 86 | impl From for MatchboxSocket { 87 | fn from(builder: WebRtcSocketBuilder) -> Self { 88 | Self::from(builder.build()) 89 | } 90 | } 91 | 92 | impl From<(WebRtcSocket, MessageLoopFuture)> for MatchboxSocket { 93 | fn from((socket, message_loop_fut): (WebRtcSocket, MessageLoopFuture)) -> Self { 94 | let task_pool = IoTaskPool::get(); 95 | let task = task_pool.spawn(message_loop_fut); 96 | MatchboxSocket(socket, Box::new(task)) 97 | } 98 | } 99 | 100 | /// A [`Command`] used to open a [`MatchboxSocket`] and allocate it as a resource. 101 | struct OpenSocket(WebRtcSocketBuilder); 102 | 103 | impl Command for OpenSocket { 104 | fn apply(self, world: &mut World) { 105 | world.insert_resource(MatchboxSocket::from(self.0)); 106 | } 107 | } 108 | 109 | /// A [`Commands`] extension used to open a [`MatchboxSocket`] and allocate it as a resource. 110 | pub trait OpenSocketExt { 111 | /// Opens a [`MatchboxSocket`] and allocates it as a resource. 112 | fn open_socket(&mut self, socket_builder: WebRtcSocketBuilder); 113 | } 114 | 115 | impl OpenSocketExt for Commands<'_, '_> { 116 | fn open_socket(&mut self, socket_builder: WebRtcSocketBuilder) { 117 | self.queue(OpenSocket(socket_builder)) 118 | } 119 | } 120 | 121 | /// A [`Command`] used to close a [`WebRtcSocket`], deleting the [`MatchboxSocket`] resource. 122 | struct CloseSocket; 123 | 124 | impl Command for CloseSocket { 125 | fn apply(self, world: &mut World) { 126 | world.remove_resource::(); 127 | } 128 | } 129 | 130 | /// A [`Commands`] extension used to close a [`WebRtcSocket`], deleting the [`MatchboxSocket`] 131 | /// resource. 132 | pub trait CloseSocketExt { 133 | /// Delete the [`MatchboxSocket`] resource. 134 | fn close_socket(&mut self); 135 | } 136 | 137 | impl CloseSocketExt for Commands<'_, '_> { 138 | fn close_socket(&mut self) { 139 | self.queue(CloseSocket) 140 | } 141 | } 142 | 143 | impl MatchboxSocket { 144 | /// Create a new socket with a single unreliable channel 145 | /// 146 | /// ```rust 147 | /// use bevy_matchbox::prelude::*; 148 | /// use bevy::prelude::*; 149 | /// 150 | /// fn open_channel_system(mut commands: Commands) { 151 | /// let room_url = "wss://matchbox.example.com"; 152 | /// let socket = MatchboxSocket::new_unreliable(room_url); 153 | /// commands.spawn(socket); 154 | /// } 155 | /// ``` 156 | pub fn new_unreliable(room_url: impl Into) -> MatchboxSocket { 157 | Self::from(WebRtcSocket::new_unreliable(room_url)) 158 | } 159 | 160 | /// Create a new socket with a single reliable channel 161 | /// 162 | /// ```rust 163 | /// use bevy_matchbox::prelude::*; 164 | /// use bevy::prelude::*; 165 | /// 166 | /// fn open_channel_system(mut commands: Commands) { 167 | /// let room_url = "wss://matchbox.example.com"; 168 | /// let socket = MatchboxSocket::new_reliable(room_url); 169 | /// commands.spawn(socket); 170 | /// } 171 | /// ``` 172 | pub fn new_reliable(room_url: impl Into) -> MatchboxSocket { 173 | Self::from(WebRtcSocket::new_reliable(room_url)) 174 | } 175 | } 176 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # [![Matchbox](https://raw.githubusercontent.com/johanhelsing/matchbox/main/images/matchbox_logo.png)](https://github.com/johanhelsing/matchbox) 2 | 3 | [![crates.io](https://img.shields.io/crates/v/matchbox_socket.svg)](https://crates.io/crates/matchbox_socket) 4 | ![MIT/Apache 2.0](https://img.shields.io/badge/license-MIT%2FApache-blue.svg) 5 | [![crates.io](https://img.shields.io/crates/d/matchbox_socket.svg)](https://crates.io/crates/matchbox_socket) 6 | [![docs.rs](https://img.shields.io/docsrs/matchbox_socket)](https://docs.rs/matchbox_socket) 7 | 8 | Painless peer-to-peer WebRTC networking for rust's native and wasm applications. 9 | 10 | The goal of the Matchbox project is to enable udp-like, unordered, unreliable p2p connections in web browsers or native to facilitate low-latency multiplayer games. 11 | 12 | Matchbox supports both unreliable and reliable data channels, with configurable ordering guarantees and variable packet retransmits. 13 | 14 | - [Tutorial for usage with Bevy and GGRS](https://johanhelsing.studio/posts/extreme-bevy) 15 | 16 | The Matchbox project contains: 17 | 18 | - [matchbox_socket](https://github.com/johanhelsing/matchbox/tree/main/matchbox_socket): A socket abstraction for Wasm or Native, with: 19 | - `ggrs`: A feature providing a [ggrs](https://github.com/gschup/ggrs) compatible socket. 20 | - [matchbox_signaling](https://github.com/johanhelsing/matchbox/tree/main/matchbox_signaling): A signaling server library, with ready to use examples 21 | - [matchbox_server](https://github.com/johanhelsing/matchbox/tree/main/matchbox_server): A ready to use full-mesh signalling server 22 | - [bevy_matchbox](https://github.com/johanhelsing/matchbox/tree/main/bevy_matchbox): A `matchbox_socket` integration for the [Bevy](https://bevyengine.org/) game engine 23 | 24 | | bevy | bevy_matchbox | 25 | | ----- | ------------- | 26 | | 0.17 | 0.13, main | 27 | | 0.16 | 0.12 | 28 | | 0.15 | 0.11 | 29 | | 0.14 | 0.10 | 30 | | 0.13 | 0.9 | 31 | | 0.12 | 0.8 | 32 | | 0.11 | 0.7 | 33 | | 0.10 | 0.6 | 34 | | < 0.9 | Unsupported | 35 | 36 | ## Examples 37 | 38 | - [simple](examples/simple): A simple communication loop using matchbox_socket 39 | - [bevy_ggrs](examples/bevy_ggrs): An example browser game, using `bevy` and `bevy_ggrs` 40 | - Live 2-player demo: 41 | - Live 4-player demo: 42 | 43 | ## How it works 44 | 45 | ![Connection](https://raw.githubusercontent.com/johanhelsing/matchbox/main/images/connection.excalidraw.svg) 46 | 47 | WebRTC allows direct connections between peers, but in order to establish those connections, some kind of signaling service is needed. `matchbox_server` is such a service. Once the connections are established, however, data will flow directly between peers, and no traffic will go through the signaling server. 48 | 49 | The signaling service needs to run somewhere all clients can reach it over http or https connections. In production, this usually means the public internet. 50 | 51 | When a client wants to join a p2p (mesh) network, it connects to the signaling service. The signaling server then notifies the peers that have already connected about the new peer (sends a `NewPeer` event). 52 | 53 | Peers then negotiate a connection through the signaling server. The initiator sends an "offer" and the recipient responds with an "answer." Once peers have enough information relayed, a [RTCPeerConnection](https://developer.mozilla.org/en-US/docs/Web/API/RTCPeerConnection) is established for each peer, which comes with one or more data channels. 54 | 55 | All of this, however, is hidden from rust application code. All you will need to do on the client side, is: 56 | 57 | - Create a new socket, and give it a signaling server url 58 | - `.await` the message loop future that processes new messages. 59 | - If you are using [Bevy](https://bevyengine.org), this is done automatically by `bevy_matchbox` (see the [`bevy_ggrs`](examples/bevy_ggrs/) example). 60 | - Otherwise, if you are using WASM, `wasm-bindgen-futures` can help (see the [`simple`](examples/simple/) example). 61 | - Alternatively, the future can be polled manually, i.e. once per frame. 62 | 63 | You can hook into the lifecycle of your socket through the socket's API, such as connection state changes. Similarly, you can send packets to peers using the socket through a simple, non-blocking method. 64 | 65 | ## Showcase 66 | 67 | Projects using Matchbox: 68 | 69 | - [NES Bundler](https://github.com/tedsteen/nes-bundler) - Transform your NES game into a single executable targeting your favorite OS! 70 | - [Cargo Space](https://helsing.studio/cargospace) (in development) - A coop 2D space game about building and flying a ship together 71 | - [Extreme Bevy](https://helsing.studio/extreme) - Simple 2-player arcade shooter 72 | - [Matchbox demo](https://helsing.studio/box_game/) 73 | - [A Janitors Nightmare](https://gorktheork.itch.io/bevy-jam-1-submission) - 2-player jam game 74 | - [Lavagna](https://github.com/alepez/lavagna) - collaborative blackboard for online meetings 75 | 76 | ## Contributing 77 | 78 | PRs welcome! 79 | 80 | If you have questions or suggestions, feel free to make an [issue](https://github.com/johanhelsing/matchbox/issues). There's also a [Discord channel](https://discord.gg/ye9UDNvqQD) if you want to get in touch. 81 | 82 | ## Thanks 83 | 84 | - A huge thanks to Ernest Wong for his [Dango Tribute experiment](https://github.com/ErnWong/dango-tribute)! `matchbox_socket` is heavily inspired its wasm-bindgen server_socket and Matchbox would probably not exist without it. 85 | 86 | ## License 87 | 88 | All code in this repository dual-licensed under either: 89 | 90 | - [MIT License](LICENSE-MIT) or 91 | - [Apache License, Version 2.0](LICENSE-APACHE) or 92 | 93 | Unless you explicitly state otherwise, any contribution intentionally submitted for inclusion in the work by you, as defined in the Apache-2.0 license, shall be dual licensed as above, without any additional terms or conditions. 94 | -------------------------------------------------------------------------------- /bevy_matchbox/src/signaling.rs: -------------------------------------------------------------------------------- 1 | use async_compat::CompatExt; 2 | use bevy::{ 3 | prelude::{Command, Commands, Resource}, 4 | tasks::{IoTaskPool, Task}, 5 | }; 6 | pub use matchbox_signaling; 7 | use matchbox_signaling::{ 8 | Error, SignalingCallbacks, SignalingServer, SignalingServerBuilder, SignalingState, 9 | topologies::{ 10 | SignalingTopology, 11 | client_server::{ClientServer, ClientServerCallbacks, ClientServerState}, 12 | full_mesh::{FullMesh, FullMeshCallbacks, FullMeshState}, 13 | }, 14 | }; 15 | use std::net::SocketAddr; 16 | 17 | /// A [`SignalingServer`] as a [`Resource`]. 18 | /// 19 | /// As a [`Resource`], with [`Commands`] 20 | /// ``` 21 | /// use std::net::Ipv4Addr; 22 | /// use bevy_matchbox::{ 23 | /// prelude::*, 24 | /// matchbox_signaling::topologies::full_mesh::{FullMesh, FullMeshState} 25 | /// }; 26 | /// use bevy::prelude::*; 27 | /// 28 | /// fn start_server_system(mut commands: Commands) { 29 | /// let builder = SignalingServerBuilder::new( 30 | /// (Ipv4Addr::UNSPECIFIED, 3536), 31 | /// FullMesh, 32 | /// FullMeshState::default(), 33 | /// ); 34 | /// commands.start_server(builder); 35 | /// } 36 | /// 37 | /// fn stop_server_system(mut commands: Commands) { 38 | /// commands.stop_server(); 39 | /// } 40 | /// ``` 41 | /// 42 | /// As a [`Resource`], directly 43 | /// ``` 44 | /// use std::net::Ipv4Addr; 45 | /// use bevy_matchbox::{ 46 | /// prelude::*, 47 | /// matchbox_signaling::topologies::full_mesh::{FullMesh, FullMeshState} 48 | /// }; 49 | /// use bevy::prelude::*; 50 | /// 51 | /// fn start_server_system(mut commands: Commands) { 52 | /// let server: MatchboxServer = SignalingServerBuilder::new( 53 | /// (Ipv4Addr::UNSPECIFIED, 3536), 54 | /// FullMesh, 55 | /// FullMeshState::default(), 56 | /// ).into(); 57 | /// 58 | /// commands.insert_resource(MatchboxServer::from(server)); 59 | /// } 60 | /// 61 | /// fn stop_server_system(mut commands: Commands) { 62 | /// commands.remove_resource::(); 63 | /// } 64 | /// ``` 65 | #[derive(Debug, Resource)] 66 | #[allow(dead_code)] // we take ownership of the task to not drop it 67 | pub struct MatchboxServer(Task>); 68 | 69 | impl From> for MatchboxServer 70 | where 71 | Topology: SignalingTopology, 72 | Cb: SignalingCallbacks, 73 | S: SignalingState, 74 | { 75 | fn from(value: SignalingServerBuilder) -> Self { 76 | MatchboxServer::from(value.build()) 77 | } 78 | } 79 | 80 | impl From for MatchboxServer { 81 | fn from(server: SignalingServer) -> Self { 82 | let task_pool = IoTaskPool::get(); 83 | let task = task_pool.spawn(server.serve().compat()); 84 | MatchboxServer(task) 85 | } 86 | } 87 | 88 | struct StartServer(SignalingServerBuilder) 89 | where 90 | Topology: SignalingTopology, 91 | Cb: SignalingCallbacks, 92 | S: SignalingState; 93 | 94 | impl Command for StartServer 95 | where 96 | Topology: SignalingTopology + Send + 'static, 97 | Cb: SignalingCallbacks, 98 | S: SignalingState, 99 | { 100 | fn apply(self, world: &mut bevy::prelude::World) { 101 | world.insert_resource(MatchboxServer::from(self.0)) 102 | } 103 | } 104 | 105 | /// A [`Commands`] extension used to start a [`MatchboxServer`]. 106 | pub trait StartServerExt< 107 | Topology: SignalingTopology, 108 | Cb: SignalingCallbacks, 109 | S: SignalingState, 110 | > 111 | { 112 | /// Starts a [`MatchboxServer`] and allocates it as a resource. 113 | fn start_server(&mut self, builder: SignalingServerBuilder); 114 | } 115 | 116 | impl StartServerExt for Commands<'_, '_> 117 | where 118 | Topology: SignalingTopology + Send + 'static, 119 | Cb: SignalingCallbacks, 120 | S: SignalingState, 121 | { 122 | fn start_server(&mut self, builder: SignalingServerBuilder) { 123 | self.queue(StartServer(builder)) 124 | } 125 | } 126 | 127 | struct StopServer; 128 | 129 | impl Command for StopServer { 130 | fn apply(self, world: &mut bevy::prelude::World) { 131 | world.remove_resource::(); 132 | } 133 | } 134 | 135 | /// A [`Commands`] extension used to stop a [`MatchboxServer`]. 136 | pub trait StopServerExt { 137 | /// Delete the [`MatchboxServer`] resource. 138 | fn stop_server(&mut self); 139 | } 140 | 141 | impl StopServerExt for Commands<'_, '_> { 142 | fn stop_server(&mut self) { 143 | self.queue(StopServer) 144 | } 145 | } 146 | 147 | impl MatchboxServer { 148 | /// Creates a new builder for a [`SignalingServer`] with full-mesh topology. 149 | pub fn full_mesh_builder( 150 | socket_addr: impl Into, 151 | ) -> SignalingServerBuilder { 152 | SignalingServer::full_mesh_builder(socket_addr) 153 | } 154 | 155 | /// Creates a new builder for a [`SignalingServer`] with client-server topology. 156 | pub fn client_server_builder( 157 | socket_addr: impl Into, 158 | ) -> SignalingServerBuilder { 159 | SignalingServer::client_server_builder(socket_addr) 160 | } 161 | } 162 | 163 | #[cfg(test)] 164 | mod tests { 165 | use crate::{ 166 | matchbox_signaling::topologies::client_server::{ClientServer, ClientServerState}, 167 | prelude::*, 168 | }; 169 | use bevy::prelude::*; 170 | use std::net::Ipv4Addr; 171 | 172 | fn start_signaling(mut commands: Commands) { 173 | let server: MatchboxServer = SignalingServerBuilder::new( 174 | (Ipv4Addr::UNSPECIFIED, 3536), 175 | ClientServer, 176 | ClientServerState::default(), 177 | ) 178 | .into(); 179 | 180 | commands.insert_resource(server); 181 | } 182 | 183 | #[test] 184 | // https://github.com/johanhelsing/matchbox/issues/350 185 | fn start_signaling_without_panics() { 186 | let mut app = App::new(); 187 | 188 | app.add_plugins(MinimalPlugins) 189 | .add_systems(Startup, start_signaling); 190 | 191 | app.update(); 192 | } 193 | } 194 | -------------------------------------------------------------------------------- /examples/bevy_ggrs/src/main.rs: -------------------------------------------------------------------------------- 1 | use bevy::{log::LogPlugin, prelude::*}; 2 | use bevy_ggrs::prelude::*; 3 | use bevy_matchbox::prelude::*; 4 | 5 | mod args; 6 | mod box_game; 7 | 8 | use args::*; 9 | use box_game::*; 10 | 11 | #[derive(Debug, Clone, Default, Eq, PartialEq, Hash, States)] 12 | enum AppState { 13 | #[default] 14 | Lobby, 15 | InGame, 16 | } 17 | 18 | const SKY_COLOR: Color = Color::srgb(0.69, 0.69, 0.69); 19 | 20 | fn main() { 21 | // read query string or command line arguments 22 | let args = Args::get(); 23 | info!("{args:?}"); 24 | 25 | App::new() 26 | .add_plugins(GgrsPlugin::::default()) 27 | .add_systems(ReadInputs, read_local_inputs) 28 | // Rollback behavior can be customized using a variety of extension methods and plugins: 29 | // The FrameCount resource implements Copy, we can use that to have minimal overhead 30 | // rollback 31 | .rollback_resource_with_copy::() 32 | // Transform and Velocity components only implement Clone, so instead we'll use that to 33 | // snapshot and rollback with 34 | .rollback_component_with_clone::() 35 | .rollback_component_with_clone::() 36 | .insert_resource(ClearColor(SKY_COLOR)) 37 | .add_plugins(DefaultPlugins.set(LogPlugin { 38 | filter: "info,wgpu_core=warn,wgpu_hal=warn,matchbox_socket=debug".into(), 39 | level: bevy::log::Level::DEBUG, 40 | ..default() 41 | })) 42 | // Some of our systems need the query parameters 43 | .insert_resource(args) 44 | .init_resource::() 45 | .init_state::() 46 | .add_systems( 47 | OnEnter(AppState::Lobby), 48 | (lobby_startup, start_matchbox_socket), 49 | ) 50 | .add_systems(Update, lobby_system.run_if(in_state(AppState::Lobby))) 51 | .add_systems(OnExit(AppState::Lobby), lobby_cleanup) 52 | .add_systems(OnEnter(AppState::InGame), setup_scene) 53 | .add_systems(Update, log_ggrs_events.run_if(in_state(AppState::InGame))) 54 | // these systems will be executed as part of the advance frame update 55 | .add_systems(GgrsSchedule, (move_cube_system, increase_frame_system)) 56 | .run(); 57 | } 58 | 59 | fn start_matchbox_socket(mut commands: Commands, args: Res) { 60 | let room_id = match &args.room { 61 | Some(id) => id.clone(), 62 | None => format!("bevy_ggrs?next={}", &args.players), 63 | }; 64 | 65 | let room_url = format!("{}/{}", &args.matchbox, room_id); 66 | info!("connecting to matchbox server: {room_url:?}"); 67 | 68 | commands.insert_resource(MatchboxSocket::new_unreliable(room_url)); 69 | } 70 | 71 | // Marker components for UI 72 | #[derive(Component)] 73 | struct LobbyText; 74 | #[derive(Component)] 75 | struct LobbyUI; 76 | 77 | fn lobby_startup(mut commands: Commands, asset_server: Res) { 78 | commands.spawn(Camera3d::default()); 79 | 80 | // All this is just for spawning centered text. 81 | commands 82 | .spawn(( 83 | Node { 84 | width: Val::Percent(100.0), 85 | height: Val::Percent(100.0), 86 | position_type: PositionType::Absolute, 87 | justify_content: JustifyContent::Center, 88 | align_items: AlignItems::FlexEnd, 89 | ..default() 90 | }, 91 | BackgroundColor(Color::srgb(0.43, 0.41, 0.38)), 92 | )) 93 | .with_children(|parent| { 94 | parent 95 | .spawn(( 96 | Node { 97 | align_self: AlignSelf::Center, 98 | justify_content: JustifyContent::Center, 99 | ..default() 100 | }, 101 | Text("Entering lobby...".to_string()), 102 | TextFont { 103 | font: asset_server.load("fonts/quicksand-light.ttf"), 104 | font_size: 96., 105 | ..default() 106 | }, 107 | TextColor(Color::BLACK), 108 | )) 109 | .insert(LobbyText); 110 | }) 111 | .insert(LobbyUI); 112 | } 113 | 114 | fn lobby_cleanup(query: Query>, mut commands: Commands) { 115 | for e in query.iter() { 116 | commands.entity(e).despawn(); 117 | } 118 | } 119 | 120 | fn lobby_system( 121 | mut app_state: ResMut>, 122 | args: Res, 123 | mut socket: ResMut, 124 | mut commands: Commands, 125 | mut text: Single<&mut Text, With>, 126 | ) { 127 | // regularly call update_peers to update the list of connected peers 128 | let Ok(peer_changes) = socket.try_update_peers() else { 129 | warn!("socket dropped"); 130 | return; 131 | }; 132 | 133 | for (peer, new_state) in peer_changes { 134 | // you can also handle the specific dis(connections) as they occur: 135 | match new_state { 136 | PeerState::Connected => info!("peer {peer} connected"), 137 | PeerState::Disconnected => info!("peer {peer} disconnected"), 138 | } 139 | } 140 | 141 | let connected_peers = socket.connected_peers().count(); 142 | let remaining = args.players - (connected_peers + 1); 143 | text.0 = format!("Waiting for {remaining} more player(s)",); 144 | if remaining > 0 { 145 | return; 146 | } 147 | 148 | info!("All peers have joined, going in-game"); 149 | 150 | // extract final player list 151 | let players = socket.players(); 152 | 153 | let max_prediction = 12; 154 | 155 | // create a GGRS P2P session 156 | let mut sess_build = SessionBuilder::::new() 157 | .with_num_players(args.players) 158 | .with_max_prediction_window(max_prediction) 159 | .with_input_delay(2); 160 | 161 | for (i, player) in players.into_iter().enumerate() { 162 | sess_build = sess_build 163 | .add_player(player, i) 164 | .expect("failed to add player"); 165 | } 166 | 167 | let channel = socket.take_channel(0).unwrap(); 168 | 169 | // start the GGRS session 170 | let sess = sess_build 171 | .start_p2p_session(channel) 172 | .expect("failed to start session"); 173 | 174 | commands.insert_resource(Session::P2P(sess)); 175 | 176 | // transition to in-game state 177 | app_state.set(AppState::InGame); 178 | } 179 | 180 | fn log_ggrs_events(mut session: ResMut>) { 181 | match session.as_mut() { 182 | Session::P2P(s) => { 183 | for event in s.events() { 184 | info!("GGRS Event: {event:?}"); 185 | } 186 | } 187 | _ => panic!("This example focuses on p2p."), 188 | } 189 | } 190 | -------------------------------------------------------------------------------- /examples/bevy_ggrs/src/box_game.rs: -------------------------------------------------------------------------------- 1 | use bevy::{platform::collections::HashMap, prelude::*}; 2 | use bevy_ggrs::{LocalInputs, LocalPlayers, prelude::*}; 3 | use bevy_matchbox::prelude::PeerId; 4 | use serde::{Deserialize, Serialize}; 5 | use std::hash::Hash; 6 | 7 | const BLUE: Color = Color::srgb(0.8, 0.6, 0.2); 8 | const ORANGE: Color = Color::srgb(0., 0.35, 0.8); 9 | const MAGENTA: Color = Color::srgb(0.9, 0.2, 0.2); 10 | const GREEN: Color = Color::srgb(0.35, 0.7, 0.35); 11 | const PLAYER_COLORS: [Color; 4] = [BLUE, ORANGE, MAGENTA, GREEN]; 12 | 13 | const INPUT_UP: u8 = 1 << 0; 14 | const INPUT_DOWN: u8 = 1 << 1; 15 | const INPUT_LEFT: u8 = 1 << 2; 16 | const INPUT_RIGHT: u8 = 1 << 3; 17 | 18 | const ACCELERATION: f32 = 18.0; 19 | const MAX_SPEED: f32 = 3.0; 20 | const FRICTION: f32 = 0.0018; 21 | const PLANE_SIZE: f32 = 5.0; 22 | const CUBE_SIZE: f32 = 0.2; 23 | 24 | // You need to define a config struct to bundle all the generics of GGRS. bevy_ggrs provides a 25 | // sensible default in `GgrsConfig`. (optional) You can define a type here for brevity. 26 | pub type BoxConfig = GgrsConfig; 27 | 28 | #[repr(C)] 29 | #[derive(Copy, Clone, Debug, PartialEq, Eq, Deserialize, Serialize, Default)] 30 | pub struct BoxInput { 31 | pub inp: u8, 32 | } 33 | 34 | #[derive(Default, Component)] 35 | pub struct Player { 36 | pub handle: usize, 37 | } 38 | 39 | // Components that should be saved/loaded need to support snapshotting. The built-in options are: 40 | // - Clone (Recommended) 41 | // - Copy 42 | // - Reflect 43 | // See `bevy_ggrs::Strategy` for custom alternatives 44 | #[derive(Default, Reflect, Component, Clone)] 45 | pub struct Velocity { 46 | pub x: f32, 47 | pub y: f32, 48 | pub z: f32, 49 | } 50 | 51 | // You can also register resources. 52 | #[derive(Resource, Default, Reflect, Hash, Clone, Copy)] 53 | #[reflect(Hash)] 54 | pub struct FrameCount { 55 | pub frame: u32, 56 | } 57 | 58 | /// Collects player inputs during [`ReadInputs`](`bevy_ggrs::ReadInputs`) and creates a 59 | /// [`LocalInputs`] resource. 60 | pub fn read_local_inputs( 61 | mut commands: Commands, 62 | keyboard_input: Res>, 63 | local_players: Res, 64 | ) { 65 | let mut local_inputs = HashMap::new(); 66 | 67 | for handle in &local_players.0 { 68 | let mut input: u8 = 0; 69 | 70 | if keyboard_input.pressed(KeyCode::KeyW) { 71 | input |= INPUT_UP; 72 | } 73 | if keyboard_input.pressed(KeyCode::KeyA) { 74 | input |= INPUT_LEFT; 75 | } 76 | if keyboard_input.pressed(KeyCode::KeyS) { 77 | input |= INPUT_DOWN; 78 | } 79 | if keyboard_input.pressed(KeyCode::KeyD) { 80 | input |= INPUT_RIGHT; 81 | } 82 | 83 | local_inputs.insert(*handle, BoxInput { inp: input }); 84 | } 85 | 86 | commands.insert_resource(LocalInputs::(local_inputs)); 87 | } 88 | 89 | pub fn setup_scene( 90 | mut commands: Commands, 91 | mut meshes: ResMut>, 92 | mut materials: ResMut>, 93 | session: Res>, 94 | mut camera_query: Query<&mut Transform, With>, 95 | ) { 96 | let num_players = match &*session { 97 | Session::SyncTest(s) => s.num_players(), 98 | Session::P2P(s) => s.num_players(), 99 | Session::Spectator(s) => s.num_players(), 100 | }; 101 | 102 | // A ground plane 103 | commands.spawn(( 104 | Mesh3d(meshes.add(Plane3d::new(Vec3::Y, Vec2::splat(PLANE_SIZE / 2.0)))), 105 | MeshMaterial3d(materials.add(StandardMaterial::from(Color::srgb(0.3, 0.5, 0.3)))), 106 | )); 107 | 108 | let r = PLANE_SIZE / 4.; 109 | let mesh = meshes.add(Mesh::from(Cuboid::from_size(Vec3::splat(CUBE_SIZE)))); 110 | 111 | for handle in 0..num_players { 112 | let rot = handle as f32 / num_players as f32 * 2. * std::f32::consts::PI; 113 | let x = r * rot.cos(); 114 | let z = r * rot.sin(); 115 | 116 | let mut transform = Transform::default(); 117 | transform.translation.x = x; 118 | transform.translation.y = CUBE_SIZE / 2.; 119 | transform.translation.z = z; 120 | let color = PLAYER_COLORS[handle % PLAYER_COLORS.len()]; 121 | 122 | // Entities which will be rolled back can be created just like any other... 123 | commands 124 | .spawn(( 125 | // ...add visual information... 126 | Mesh3d(mesh.clone()), 127 | MeshMaterial3d(materials.add(StandardMaterial::from(color))), 128 | transform, 129 | // ...flags... 130 | Player { handle }, 131 | // ...and components which will be rolled-back... 132 | Velocity::default(), 133 | )) 134 | // ...just ensure you call `add_rollback()` 135 | // This ensures a stable ID is available for the rollback system to refer to 136 | .add_rollback(); 137 | } 138 | 139 | // light 140 | commands.spawn((PointLight::default(), Transform::from_xyz(-4.0, 8.0, 4.0))); 141 | // camera 142 | for mut transform in camera_query.iter_mut() { 143 | *transform = Transform::from_xyz(-2.0, 2.5, 5.0).looking_at(Vec3::ZERO, Vec3::Y); 144 | } 145 | } 146 | 147 | // Example system, manipulating a resource, will be added to the rollback schedule. 148 | // Increases the frame count by 1 every update step. If loading and saving resources works 149 | // correctly, you should see this resource rolling back, counting back up and finally increasing by 150 | // 1 every update step 151 | #[allow(dead_code)] 152 | pub fn increase_frame_system(mut frame_count: ResMut) { 153 | frame_count.frame += 1; 154 | } 155 | 156 | // Example system that moves the cubes, will be added to the rollback schedule. 157 | // Filtering for the rollback component is a good way to make sure your game logic systems 158 | // only mutate components that are being saved/loaded. 159 | #[allow(dead_code)] 160 | pub fn move_cube_system( 161 | mut query: Query<(&mut Transform, &mut Velocity, &Player), With>, 162 | // ^------^ Added by 163 | // `add_rollback` earlier 164 | inputs: Res>, 165 | // Thanks to RollbackTimePlugin, this is rollback safe 166 | time: Res