Transport-agnostic async Rust client for the Signal Fish multiplayer signaling protocol. Connect to a Signal Fish server over any bidirectional transport, authenticate, join rooms, and receive strongly-typed events — all through a simple channel-based API.
🤖 AI Disclosure
This project was developed with substantial AI assistance. The protocol design and core technology concepts were created entirely by humans, but the vast majority of the code, documentation, and tests were written with the help of Claude Opus 4.6 and Codex 5.3. Human oversight covered code review and architectural decisions, but day-to-day implementation was primarily AI-driven. This transparency is provided so users can make informed decisions about using this crate.
- Transport-agnostic — implement the
Transporttrait for any backend (WebSocket, TCP, QUIC, WebRTC data channels, etc.) - Wire-compatible — all protocol types match the Signal Fish server's v2 JSON format exactly
- Feature-gated WebSocket transport — the default
transport-websocketfeature provides a ready-to-useWebSocketTransport - Event-driven — receive typed
SignalFishEvents via a Tokio MPSC channel - Structured errors —
SignalFishError(9 variants) andErrorCode(40 variants) for precise error handling - Full protocol coverage — 11 client message types, 24 server message types, 26 event variants
- Configurable — tune event channel capacity, shutdown timeout, and more via
SignalFishConfigbuilder methods - WebAssembly ready — compiles to
wasm32-unknown-unknownandwasm32-unknown-emscriptenwith zero unsafe panics - Emscripten WebSocket transport — the
transport-websocket-emscriptenfeature providesEmscriptenWebSocketTransportwith raw FFI to Emscripten's C API - Polling client —
SignalFishPollingClientdrives the protocol from a game loop without an async runtime, ideal for Godot 4.5 web exports
[dependencies]
signal-fish-client = "0.4.1"Without the built-in WebSocket transport (bring your own):
[dependencies]
signal-fish-client = { version = "0.4.1", default-features = false }use signal_fish_client::{
WebSocketTransport, SignalFishClient, SignalFishConfig,
JoinRoomParams, SignalFishEvent,
};
#[tokio::main]
async fn main() -> Result<(), signal_fish_client::SignalFishError> {
// 1. Connect a WebSocket transport to the signaling server.
let transport = WebSocketTransport::connect("ws://localhost:3536/ws").await?;
// 2. Build a client config with your application ID.
let config = SignalFishConfig::new("mb_app_abc123");
// 3. Start the client — returns a handle and an event receiver.
// The client automatically sends Authenticate on start.
let (mut client, mut event_rx) = SignalFishClient::start(transport, config);
// 4. Process events — wait for Authenticated before joining a room.
while let Some(event) = event_rx.recv().await {
match event {
SignalFishEvent::Authenticated { app_name, .. } => {
println!("Authenticated as {app_name}");
// Now it's safe to join a room.
client.join_room(JoinRoomParams::new("my-game", "Alice"))?;
}
SignalFishEvent::RoomJoined { room_code, .. } => {
println!("Joined room {room_code}");
}
SignalFishEvent::Disconnected { .. } => break,
_ => {}
}
}
// 5. Shut down gracefully.
client.shutdown().await;
Ok(())
}| Feature | Default | Description |
|---|---|---|
transport-websocket |
yes | Built-in WebSocket transport via tokio-tungstenite and futures-util |
transport-websocket-emscripten |
no | Emscripten WebSocket transport via raw FFI to <emscripten/websocket.h> |
tokio-runtime |
yes (via transport-websocket) |
Tokio runtime integration (rt, time); disable for pure WASM targets |
| Module | Purpose |
|---|---|
client |
SignalFishClient handle, SignalFishConfig, JoinRoomParams |
event |
SignalFishEvent enum (24 server + 2 synthetic variants) |
protocol |
Wire-compatible ClientMessage (11) / ServerMessage (24) types |
error |
SignalFishError unified error type (9 variants) |
error_codes |
ErrorCode enum (40 server error code variants) |
transport |
Transport trait for pluggable backends |
transports |
Built-in transport implementations (WebSocketTransport) |
polling_client |
SignalFishPollingClient — synchronous, game-loop-driven client |
Full lifecycle: connect, authenticate, join a room, handle events, and shut down gracefully with Ctrl+C support.
cargo run --example basic_lobby
# Override the server URL:
SIGNAL_FISH_URL=ws://my-server:3536/ws cargo run --example basic_lobbyImplement a channel-based loopback transport, wire it into the client, and verify events flow correctly — no network required.
cargo run --example custom_transportSee examples/custom_transport.rs.
The SDK compiles to WebAssembly. See the WebAssembly Guide for Godot gdext integration examples and build instructions.
Implement the Transport trait to plug in any I/O backend:
use async_trait::async_trait;
use signal_fish_client::{SignalFishError, Transport};
struct MyTransport { /* … */ }
#[async_trait]
impl Transport for MyTransport {
async fn send(&mut self, message: String) -> Result<(), SignalFishError> {
// Send the JSON text message over your transport.
todo!()
}
async fn recv(&mut self) -> Option<Result<String, SignalFishError>> {
// Receive the next JSON text message.
// Return None when the connection is closed cleanly.
todo!()
}
async fn close(&mut self) -> Result<(), SignalFishError> {
// Gracefully shut down the connection.
todo!()
}
}Key requirements:
recv()must be cancel-safe (it's used insidetokio::select!)- Connection setup happens before constructing the transport — the trait only covers message I/O
- The transport must be
Send + 'static(required by the async task boundary)
The SDK supports two WASM targets:
| Target | Use Case | Transport | Client |
|---|---|---|---|
wasm32-unknown-unknown |
Browser apps (wasm-pack, wasm-bindgen) | Bring your own | SignalFishPollingClient (with polling-client feature) |
wasm32-unknown-emscripten |
Godot 4.5 web exports (gdext) | EmscriptenWebSocketTransport |
SignalFishPollingClient |
use signal_fish_client::{
EmscriptenWebSocketTransport, SignalFishPollingClient,
SignalFishConfig, JoinRoomParams, SignalFishEvent,
};
// 1. Connect (synchronous — no .await needed).
let transport = EmscriptenWebSocketTransport::connect("wss://server/ws")
.expect("WebSocket creation failed");
// 2. Create the polling client (auto-sends Authenticate).
let config = SignalFishConfig::new("mb_app_abc123");
let mut client = SignalFishPollingClient::new(transport, config);
// 3. Each frame, poll and handle events.
for event in client.poll() {
match event {
SignalFishEvent::Authenticated { app_name, .. } => {
println!("Authenticated as {app_name}");
client.join_room(JoinRoomParams::new("my-game", "Alice")).ok();
}
SignalFishEvent::RoomJoined { room_code, .. } => {
println!("Joined room {room_code}");
}
_ => {}
}
}# Install prerequisites
rustup toolchain install nightly
rustup component add rust-src --toolchain nightly
# Build
cargo +nightly build -Zbuild-std \
--target wasm32-unknown-emscripten \
--no-default-features \
--features transport-websocket-emscriptenSee the WebAssembly Guide for the full reference including Godot integration examples and toolchain setup.
A unified script runs all CI checks locally:
# Run all checks (matches CI exactly)
bash scripts/check-all.sh
# Quick mode: fmt + clippy + test only
bash scripts/check-all.sh --quickcargo fmt && cargo clippy --all-targets --all-features -- -D warnings && cargo test --all-features| Command | CI Workflow | Install |
|---|---|---|
cargo deny check |
ci.yml | cargo install cargo-deny |
cargo audit |
security-supply-chain.yml | cargo install cargo-audit |
bash scripts/check-no-panics.sh |
no-panics.yml | (built-in) |
typos |
docs-validation.yml | cargo install typos-cli |
markdownlint-cli2 "**/*.md" |
docs-validation.yml | npm install -g markdownlint-cli2 |
lychee --config .lychee.toml "**/*.md" |
docs-validation.yml | cargo install lychee |
cargo machete |
unused-deps.yml | cargo install cargo-machete |
cargo semver-checks check-release |
semver-checks.yml | cargo install cargo-semver-checks |
bash scripts/check-workflows.sh |
workflow-lint.yml | (built-in) |
cargo +nightly miri test --test protocol_tests |
deep-safety.yml | rustup component add miri --toolchain nightly |
cd fuzz && cargo +nightly fuzz run ... |
deep-safety.yml | cargo install cargo-fuzz |
cargo mutants --file src/protocol.rs ... |
deep-safety.yml | cargo install cargo-mutants |
cargo llvm-cov --all-features --summary-only |
coverage.yml | cargo install cargo-llvm-cov + rustup component add llvm-tools-preview |
1.85.0
Tested against the latest stable Rust and the declared MSRV. Bumping the MSRV is considered a minor version change.
MIT — Copyright (c) 2025-2026 Ambiguous Interactive