Skip to content

WebAssembly (WASM)

The Signal Fish Client SDK supports two WebAssembly targets, each serving a different runtime environment. This guide covers both targets end-to-end: compilation, transport selection, game-loop integration, and Godot gdext usage.


Overview

Target Runtime Transport Client Use Case
wasm32-unknown-unknown Browser sandbox / wasm-pack Bring your own SignalFishPollingClient (with polling-client feature) Generic browser apps, Bevy, wasm-bindgen projects
wasm32-unknown-emscripten Godot/Emscripten runtime GodotWebSocketTransport; EmscriptenWebSocketTransport only with a custom link-enabled host SignalFishPollingClient Godot native/web exports

The two targets differ in what the compiled WASM module can access at runtime. wasm32-unknown-unknown runs inside a pure sandbox with no OS — you must bridge all I/O through JavaScript. wasm32-unknown-emscripten links against Emscripten's C sysroot. Access to an Emscripten C API still depends on the final host linking the corresponding JavaScript library; headers alone are not enough.

graph TD
    subgraph "wasm32-unknown-unknown"
        A["Core SDK types<br/>(no transport, no runtime)"] --> B["Your Transport impl<br/>(web-sys, wasm-bindgen)"]
        B --> C["Browser WebSocket"]
    end

    subgraph "wasm32-unknown-emscripten"
        D["GodotWebSocketTransport"] --> E["Godot WebSocketPeer"]
        E --> F["Browser WebSocket"]
        G["SignalFishPollingClient"] --> D
        H["Godot _process()"] --> G
    end

Target: wasm32-unknown-unknown

This is the standard Rust WASM target for browser and wasm-pack projects. It compiles the SDK's core types — Transport trait, protocol types, SignalFishEvent, SignalFishError, and SignalFishConfig — without pulling in any transport or async runtime.

What you get

  • All protocol types (ClientMessage, ServerMessage, payload structs)
  • The Transport trait definition
  • SignalFishConfig and JoinRoomParams builders
  • SignalFishEvent and SignalFishError enums

What you do not get

  • No built-in transport (WebSocket over TCP is unavailable in the browser sandbox)
  • No Tokio runtime (tokio::net::TcpStream does not compile to WASM)
  • No SignalFishClient::start() (requires tokio::spawn)

Building

Bash
rustup target add wasm32-unknown-unknown
cargo build --target wasm32-unknown-unknown --no-default-features

Default features must be disabled

The default transport-websocket feature depends on tokio-tungstenite, which requires TCP sockets. Always pass --no-default-features when building for any WASM target.

Bring your own transport

Implement the Transport trait using a browser-compatible WebSocket binding (e.g., web-sys, gloo-net, or wasm-bindgen). The trait requires the three object-safe polling methods poll_send, poll_recv, and poll_close, plus a prompt, idempotent abort path, as documented on the Transport page.

Compatibility

This target is compatible with:

  • wasm-bindgen / wasm-pack
  • Bevy (with WASM support)
  • Any framework that compiles to wasm32-unknown-unknown

Target: wasm32-unknown-emscripten

This target supports a synchronous polling client. The bundled EmscriptenWebSocketTransport is an advanced integration for hosts or custom export templates that explicitly link Emscripten's WebSocket library.

Official Godot templates

Add the lockstep signal-fish-client-godot adapter for the supported Godot 4.5 native/web path. It wraps Godot's own WebSocketPeer, supports gdext's no-thread WASM mode, and needs no separately linked Emscripten WebSocket symbols.

What you get

  • Everything from wasm32-unknown-unknown, plus:
  • GodotWebSocketTransport — Godot 4.5 native/web WebSocketPeer wrapper
  • Optionally, EmscriptenWebSocketTransport for custom link-enabled hosts
  • SignalFishPollingClient — synchronous, game-loop-driven client

Prerequisites

Requirement Version Notes
Rust nightly 2026-03-01 Pinned tier 3 toolchain used by CI
rust-src component (matches nightly) Required by -Zbuild-std to build std from source
Emscripten SDK 3.1.74 Provides the sysroot and system libraries

Feature flags

For Godot 4.5 native and official web exports, enable the supported transport:

TOML
[dependencies]
godot = { version = "0.5.4", features = ["api-custom", "experimental-wasm", "experimental-wasm-nothreads", "lazy-function-tables"] }
signal-fish-client = { git = "https://github.com/Ambiguous-Interactive/signal-fish-client-rust", default-features = false, features = ["polling-client"] }
signal-fish-client-godot = { git = "https://github.com/Ambiguous-Interactive/signal-fish-client-rust" }

Only for a custom Emscripten host that explicitly links its WebSocket library, enable the advanced raw-FFI transport:

TOML
[dependencies]
signal-fish-client = { git = "https://github.com/Ambiguous-Interactive/signal-fish-client-rust", default-features = false, features = ["transport-websocket-emscripten"] }

These snippets use main because the ownership, bounded-work, and admission guarantees documented below are listed under Unreleased. Use the published 0.10.0 dependency with its versioned API docs when you do not need those fixes yet.

The adapter supports godot-rust 0.4.5 through 0.5.x and requires Rust 1.94 or newer; the core remains compatible with Rust 1.87. After changing the direct godot version, run cargo tree -d. A valid integration contains one godot and one version of every godot-* binding crate. Duplicate binding versions produce distinct Gd types and must be aligned in the lockfile before use.

Building

Bash
export GODOT4_BIN=/path/to/Godot_v4.5-stable_linux.x86_64
export BINDGEN_EXTRA_CLANG_ARGS_wasm32_unknown_emscripten="--target=wasm32-unknown-emscripten --sysroot=${EMSDK}/upstream/emscripten/cache/sysroot -D__EMSCRIPTEN__"
export RUSTFLAGS="-Z unstable-options -C panic=immediate-abort -C link-arg=-sSIDE_MODULE=2 -C llvm-args=-enable-emscripten-cxx-exceptions=0 -Z default-visibility=hidden -Z link-native-libraries=no"
cargo +nightly-2026-03-01 build -Zbuild-std=std \
    --target wasm32-unknown-emscripten --release

Substitute transport-websocket-emscripten only for the custom-host path.

Note

api-custom regenerates the GDExtension interface with the web target's 32-bit pointer layout. The Emscripten sysroot keeps bindgen from selecting host C headers, and -Zbuild-std=std is why rust-src is required.


EmscriptenWebSocketTransport

A Transport implementation backed by Emscripten's built-in <emscripten/websocket.h> C API. It uses raw FFI calls to create a browser WebSocket and a std::sync::mpsc channel to bridge asynchronous C callbacks into the transport's poll_recv() method.

Construction

Rust
use signal_fish_client::EmscriptenWebSocketTransport;

let transport = EmscriptenWebSocketTransport::connect("wss://example.com/v2/ws")?;

connect() is synchronous — the WebSocket object is created immediately, but the connection handshake completes asynchronously in the browser. The transport returns Pending before onopen, so the polling client retains each queued command until Emscripten can accept it.

Returns Result<EmscriptenWebSocketTransport, SignalFishError>. On failure the error is SignalFishError::Io (e.g., invalid URL or Emscripten API failure).

How it works

sequenceDiagram
    participant App as Game Loop
    participant PC as SignalFishPollingClient
    participant T as EmscriptenWebSocketTransport
    participant EM as Emscripten C API
    participant WS as Browser WebSocket

    Note over EM,WS: C callbacks fire on the main thread
    WS-->>EM: onmessage / onerror / onclose
    EM-->>T: std::sync::mpsc::Sender::send(IncomingEvent)

    App->>PC: poll() before onopen
    PC->>T: transport.poll_send(frame) [noop waker]
    T-->>PC: Poll::Pending (frame remains caller-owned)
    PC->>T: transport.poll_recv() [noop waker]
    T->>T: mpsc::Receiver::try_recv()
    T-->>PC: Poll::Pending
    WS-->>EM: onopen
    EM-->>T: IncomingEvent::Open
    App->>PC: poll() observes open
    PC-->>App: SignalFishEvent::Connected
    App->>PC: next poll()
    PC->>T: transport.poll_send(frame) [noop waker]
    T->>EM: emscripten_websocket_send_utf8_text()
    EM->>WS: WebSocket.send()
    PC-->>App: Vec<SignalFishEvent>

The callback bridge pattern works as follows:

  1. When the WebSocket is created, four C callbacks are registered via emscripten_websocket_set_on*_callback_on_thread() — for open, message, error, and close events.
  2. Each callback pushes an IncomingEvent onto a std::sync::mpsc::Sender.
  3. When poll_recv() is called, it calls try_recv() on the channel receiver:
    • If a message is available, it returns Poll::Ready(Some(Ok(text))).
    • If no messages are buffered, it returns Poll::Pending without registering the supplied waker.

Threading model

On the supported single-threaded Emscripten configuration, WebSocket callbacks fire on the main thread between frames. Transport deliberately has no Send bound; this transport is therefore usable by SignalFishPollingClient without an unsafe thread-safety claim. The async client adds its own Send + 'static bound and does not accept this main-thread transport.

Compatibility

Polling client only

EmscriptenWebSocketTransport is designed exclusively for use with SignalFishPollingClient. It is not compatible with SignalFishClient::start(), which requires a Tokio runtime to spawn a background task and requires Send + 'static. This transport is driven by the engine's frame loop through the polling methods.

Connection timing

Connection timing — Connected vs. WebSocket onopen

SignalFishPollingClient emits [SignalFishEvent::Connected] once the transport's Transport::is_ready() method returns true. For EmscriptenWebSocketTransport, this happens after the browser's WebSocket onopen callback fires — meaning Connected genuinely reflects a completed handshake.

Commands queued before Connected (including the automatic Authenticate message) remain owned by SignalFishPollingClient. The transport returns Pending without consuming the frame until it observes onopen; a later poll() retries the exact same frame, in order. The browser only receives the command after its synchronous send API accepts it.

The async SignalFishClient uses the same readiness boundary. Built-in WebSocketTransport::connect(url).await is already ready, while a custom asynchronous-handshake transport defers Connected and must wake its registered I/O waker when readiness changes.


SignalFishPollingClient

A synchronous, polling-based alternative to SignalFishClient. It does not spawn a background task or require an async runtime. Instead, the caller drives the client by calling poll() once per frame from the game loop.

Not just for wasm

The polling client is the right choice for any frame-driven environment, native game loops included. SignalFishClient::start spawns its transport loop with tokio::spawn, so it needs a driven tokio runtime (#[tokio::main], block_on, worker threads — a current_thread runtime is fine as long as it is actually running). Manually "ticking" a runtime once per frame starves the loop and makes messages appear to vanish. If you cannot keep a runtime driven, pump SignalFishPollingClient instead — see Driving the Client.

Construction

Rust
use signal_fish_client::{SignalFishPollingClient, SignalFishConfig};
use signal_fish_client_godot::GodotWebSocketTransport;

let transport = GodotWebSocketTransport::connect("wss://example.com/v2/ws")?;
let config = SignalFishConfig::new("mb_app_abc123");
let mut client = SignalFishPollingClient::new(transport, config);

The standard connect and connect_with_options paths configure Godot's inbound_buffer_size to 8 MiB before starting the connection. This is a protective client default sized above the roughly 6.25 MiB aggregate snapshots that a default Server 0.7 deployment can legally produce, not a protocol maximum or a guarantee that every larger message is rejected at exactly that boundary. Godot may reserve roughly twice that amount per peer across its receive ring and packet buffer.

Applications with a different trusted size contract can create and configure a Godot WebSocketPeer, call connect_to_url, and pass it through from_peer or from_peer_with_options. Those advanced constructors preserve the caller's buffer choices. Browser and engine implementations still own frame/message assembly before the SDK receives a complete packet.

Allow the browser's exact Origin on Server 0.7

Browsers attach an Origin header to the WebSocket upgrade, and Signal Fish Server 0.7 validates it. Configure the server's security.cors_origins (or SIGNAL_FISH__SECURITY__CORS_ORIGINS) with the exact HTTPS origin that serves the game. Use '*' only for isolated local or CI fixtures, never as the production default.

For Godot buffering control, use GodotWebSocketTransport::connect_with_options. The default adaptive policy targets 50 ms of backend buffering with a 4 KiB floor and 32 KiB ceiling. One individually oversized frame may escape the latency watermark when the buffer is empty, but never Godot's native capacity boundary. Select Fixed for an explicit byte watermark or NativeCapacity to disable the latency watermark while retaining capacity-safe preflight. Pair this independently with SignalFishPollingClient::new_with_options for per-frame work and close-policy tuning. Backend-accepted, browser-buffered, and peer-delivered are separate stages; inspect transport_diagnostics() rather than treating buffered zero as per-frame completion. For a stronger admission audit, client.transport().admission_watermark_violations() must remain zero; client.transport().one_frame_escape_frames() counts uses of the documented empty-buffer exception, while one_frame_escape_bytes() retains their cumulative payload bytes. Together they distinguish one individually oversized frame from the same byte total spread across multiple escapes. The read-only transport() accessor is for transport-specific diagnostics; continue to drive all protocol and I/O progress through poll().

For rollback networking, see the Godot + Fortress integration, including the bounded relay adapter and multi-process browser test used in CI.

The constructor immediately queues an Authenticate message (just like SignalFishClient::start). It is offered on subsequent poll() calls as the handshake, transport admission, and work budget allow.

Game loop integration

Call poll() once per frame. It transfers queued outgoing commands and processes incoming messages up to the configured work budget, then returns a Vec of events for that cycle. Remaining work stays ordered for later polls:

Rust
// In your game loop / _process(delta):
for event in client.poll() {
    match event {
        SignalFishEvent::Authenticated { .. } => {
            client.join_room(JoinRoomParams::new("my-game", "Player1")).ok();
        }
        SignalFishEvent::RoomJoined { room_code, .. } => {
            // You are in the room
        }
        SignalFishEvent::Disconnected { .. } => {
            // Handle disconnection
        }
        _ => {}
    }
}

How poll() works internally

poll() uses std::task::Waker::noop() to create a context and invokes the transport polling contract synchronously:

  1. Creates a std::task::Context with a noop waker.
  2. Pops queued commands up to both the send frame and byte budgets, serializes each, and polls transport.poll_send. Pending before ownership transfer preserves the exact frame for the next cycle.
  3. Loops calling transport.poll_recv(). Each ready frame is decoded and converted to a SignalFishEvent, and appended to the output vector. The loop breaks on Pending (no more buffered messages this frame) or Ready(None) (transport closed), or when the receive frame/byte budget is reached.

The fixed defaults are 64 frames/64 KiB for sends and the same for receives. Zero limits clamp to one; an individually oversized frame can consume one poll by itself. PollingClientOptions also selects Abandon (default) or Flush close behavior. Both are bounded by SignalFishConfig::shutdown_timeout. Deadline expiry, a graceful-close error, or dropping the polling client before close completes invokes the transport's required synchronous abort fallback; the driver performs no later transport polling. Use polling_stats() for client-owned queue depth, budget exhaustion, and deadline counters; use transport_diagnostics() for backend buffering, watermark, acceptance, and capacity counters. Backend acceptance is not peer delivery. Use queue_age_stats() alongside depth to detect a fixed-depth queue whose oldest command is becoming progressively stale. The current and peak ages are sampled on polls and queue mutations. Authentication/setup time contributes until reset_queue_age_peak() is called; backend acceptance stops the age immediately but does not mean the peer has received the frame. Godot's WebSocketPeer reports its outbound buffer separately, matching the WebSocket bufferedAmount contract: buffering after acceptance is backend-owned, not proof of peer delivery.

API reference

Driving, lifecycle, and command methods

Command methods queue protocol work for a later poll() cycle. They return SignalFishError::SendBufferFull without queuing when the bounded command queue is full, or SignalFishError::NotConnected if the transport has closed. Protocol-v3-only methods also return SignalFishError::ProtocolUnsupported until v3 is negotiated. Room commands additionally fail fast with NotInRoom, AlreadyInRoom, WrongRoomRole, RoomOperationPending, or AuthorityRequired as appropriate; these checks happen before queue capacity.

Method Signature Description
poll() fn poll(&mut self) -> Vec<SignalFishEvent> Process bounded sends and receives, then return this cycle's events.
join_room(params) fn join_room(&mut self, params: JoinRoomParams) -> Result<()> Join or create a room.
leave_room() fn leave_room(&mut self) -> Result<()> Leave the current room.
set_ready() fn set_ready(&mut self) -> Result<()> Signal readiness.
start_game() fn start_game(&mut self) -> Result<()> Request explicit game start using the v2 command semantics; this method is not v3-gated.
send_game_data(data) fn send_game_data(&mut self, data: serde_json::Value) -> Result<()> Send JSON game data to other players.
send_game_data_with_delivery(data, delivery) fn send_game_data_with_delivery(&mut self, data: serde_json::Value, delivery: GameDataDelivery) -> Result<()> Send JSON game data with an explicit delivery policy; Latest and Volatile require v3.
send_binary_game_data(payload) fn send_binary_game_data(&mut self, payload: Vec<u8>) -> Result<()> Queue an opaque protocol-v3 binary game-data payload.
request_authority(flag) fn request_authority(&mut self, become_authority: bool) -> Result<()> Request or relinquish authority.
provide_connection_info(info) fn provide_connection_info(&mut self, info: ConnectionInfo) -> Result<()> Provide P2P connection info.
reconnect(player_id, room_id, auth_token) fn reconnect(&mut self, player_id: PlayerId, room_id: RoomId, auth_token: String) -> Result<()> Reconnect to a room after disconnection.
join_as_spectator(game, room, name) fn join_as_spectator(&mut self, game_name: String, room_code: String, spectator_name: String) -> Result<()> Join a room as a spectator.
leave_spectator() fn leave_spectator(&mut self) -> Result<()> Leave spectator mode.
send_signal(to, signal) fn send_signal(&mut self, to: PlayerId, signal: impl Into<PeerSignal>) -> Result<()> Relay a typed WebRTC signal on protocol v3.
send_signal_for_generation(to, generation, signal) fn send_signal_for_generation(&mut self, to: PlayerId, generation: Option<SessionGeneration>, signal: impl Into<PeerSignal>) -> Result<()> Relay driver output only while its authoritative plan generation remains current.
send_offer(to, sdp) fn send_offer(&mut self, to: PlayerId, sdp: impl Into<String>) -> Result<()> Relay a protocol-v3 SDP offer.
send_answer(to, sdp) fn send_answer(&mut self, to: PlayerId, sdp: impl Into<String>) -> Result<()> Relay a protocol-v3 SDP answer.
send_ice_candidate(to, candidate) fn send_ice_candidate(&mut self, to: PlayerId, candidate: impl Into<String>) -> Result<()> Relay a protocol-v3 ICE candidate.
send_raw_signal(to, signal) fn send_raw_signal(&mut self, to: PlayerId, signal: serde_json::Value) -> Result<()> Relay an unmodeled protocol-v3 signal value.
send_raw_signal_for_generation(to, generation, signal) fn send_raw_signal_for_generation(&mut self, to: PlayerId, generation: Option<SessionGeneration>, signal: serde_json::Value) -> Result<()> Generation-bound raw signaling escape hatch.
report_transport_status(transport, connected) fn report_transport_status(&mut self, transport: TransportKind, connected: bool) -> Result<()> Report protocol-v3 data-path connectivity.
ping() fn ping(&mut self) -> Result<()> Send a heartbeat ping.
close() fn close(&mut self) Start the configured bounded close lifecycle; keep polling while is_closing().

State accessors

All state accessors are synchronous (no async, no Mutex — single-threaded environment).

Method Signature Description
is_connected() fn is_connected(&self) -> bool Whether the client owns a nonterminal transport attempt, including connecting.
is_transport_ready() fn is_transport_ready(&self) -> bool Whether the driver observed the transport handshake complete.
is_closing() fn is_closing(&self) -> bool Whether the bounded close lifecycle still needs polling.
is_authenticated() fn is_authenticated(&self) -> bool Whether the server confirmed authentication.
room_role() fn room_role(&self) -> Option<RoomRole> Server-confirmed player/spectator role, or None outside a room.
current_player_id() fn current_player_id(&self) -> Option<PlayerId> Legacy name for the local player-or-spectator participant ID; use one snapshot() to interpret it atomically with room_role.
current_room_id() fn current_room_id(&self) -> Option<RoomId> The current room ID, if in a room.
current_room_code() fn current_room_code(&self) -> Option<&str> The current room code, if in a room.
negotiated_protocol_version() fn negotiated_protocol_version(&self) -> Option<u16> Negotiated protocol version; None before negotiation or for the v2 relay floor.
supports_mesh() fn supports_mesh(&self) -> bool Negotiated v3 + WebRTC + Host/Mesh capability; not active-plan state.
session_topology() fn session_topology(&self) -> Option<Topology> Topology selected by the latest authoritative plan.
session_transport() fn session_transport(&self) -> Option<TransportKind> Transport selected by the latest authoritative plan.
is_p2p_active() fn is_p2p_active(&self) -> bool Whether the selected plan uses a Host or Mesh topology.
send_capacity() fn send_capacity(&self) -> usize Remaining slots in the bounded command queue.
max_send_capacity() fn max_send_capacity(&self) -> usize Configured command-queue capacity.
stats() fn stats(&self) -> ClientStats Cumulative game-data and undecodable-message counters.
polling_stats() fn polling_stats(&self) -> PollingStats Client queue, work-budget, abandonment, and deadline diagnostics.
queue_age_stats() fn queue_age_stats(&self) -> PollingQueueAgeStats Sampled current/peak age of the oldest client-owned outbound item.
reset_queue_age_peak() fn reset_queue_age_peak(&mut self) Refresh current age and reset the sampled peak to it.
transport_diagnostics() fn transport_diagnostics(&self) -> TransportDiagnostics Backend acceptance, buffering, watermark, and capacity diagnostics.
transport() fn transport(&self) -> &T Borrow transport-specific read-only diagnostics; I/O still advances only through poll().
snapshot() fn snapshot(&self) -> ClientSnapshot Return coherent connection readiness, room, token, negotiation, selected plan, generation, and quarantine state.

Comparison with SignalFishClient

SignalFishPollingClient mirrors SignalFishClient's common synchronous commands: both use &mut self, and state accessors use &self. The polling client has no asynchronous waiting sends or shutdown() — drive it with poll() and use close() instead.


Godot Integration Example (gdext)

Use SignalFishPollingClient with GodotWebSocketTransport in a Godot 4.5 GDExtension Node. The transport delegates networking to WebSocketPeer, so this code works for native builds and official no-thread web exports.

Cargo.toml

TOML
[package]
name = "my-godot-game"
version = "0.1.0"
edition = "2021"

[lib]
crate-type = ["cdylib"]

[dependencies]
godot = { version = "0.5.4", features = ["api-custom", "experimental-wasm", "experimental-wasm-nothreads", "lazy-function-tables"] }
signal-fish-client = { git = "https://github.com/Ambiguous-Interactive/signal-fish-client-rust", default-features = false, features = ["polling-client"] }
signal-fish-client-godot = { git = "https://github.com/Ambiguous-Interactive/signal-fish-client-rust" }
serde_json = "1.0"  # Required for send_game_data(serde_json::Value)

GDExtension Node

Rust
use godot::prelude::*;
use signal_fish_client::{JoinRoomParams, SignalFishConfig, SignalFishEvent,
    SignalFishPollingClient};
use signal_fish_client_godot::GodotWebSocketTransport;

#[derive(GodotClass)]
#[class(base=Node)]
struct SignalFishNode {
    base: Base<Node>,
    client: Option<SignalFishPollingClient<GodotWebSocketTransport>>,
}

#[godot_api]
impl INode for SignalFishNode {
    fn init(base: Base<Node>) -> Self {
        Self {
            base,
            client: None,
        }
    }

    fn ready(&mut self) {
        let transport = GodotWebSocketTransport::connect("wss://example.com/v2/ws")
            .expect("failed to create WebSocket");
        let config = SignalFishConfig::new("mb_app_abc123");
        self.client = Some(SignalFishPollingClient::new(transport, config));
        godot_print!("SignalFish client created");
    }

    fn process(&mut self, _delta: f64) {
        let Some(client) = &mut self.client else { return };

        for event in client.poll() {
            match event {
                SignalFishEvent::Connected => {
                    godot_print!("Connected to Signal Fish server");
                }
                SignalFishEvent::Authenticated { app_name, .. } => {
                    godot_print!("Authenticated as {}", app_name);
                    let params = JoinRoomParams::new("my-game", "GodotPlayer")
                        .with_max_players(4);
                    client.join_room(params).ok();
                }
                SignalFishEvent::RoomJoined { room_code, player_id, .. } => {
                    godot_print!("Joined room {} as {}", room_code, player_id);
                    client.set_ready().ok();
                }
                SignalFishEvent::GameData { from_player, data, .. } => {
                    godot_print!("Game data from {}: {}", from_player, data);
                }
                SignalFishEvent::PlayerJoined { player } => {
                    godot_print!("{} joined the room", player.name);
                }
                SignalFishEvent::PlayerLeft { player_id, .. } => {
                    godot_print!("Player {} left", player_id);
                }
                SignalFishEvent::GameStarting { peer_connections } => {
                    godot_print!("Game starting with {} peers", peer_connections.len());
                }
                SignalFishEvent::Disconnected { reason, .. } => {
                    godot_print!(
                        "Disconnected: {}",
                        reason.as_deref().unwrap_or("unknown")
                    );
                    self.client = None;
                    return;
                }
                _ => {}
            }
        }
    }
}

How it works

  1. ready() — creates the Godot WebSocket transport and the polling client. Authentication is queued automatically.
  2. process(delta) — called every frame by Godot. Calls poll() to flush outgoing messages and drain incoming events. Each event is handled inline.
  3. Disconnection — call client.close() and keep processing frames while client.is_closing() so Godot can complete the close handshake.

No GDScript networking glue is needed. The fixture under tests/godot-web-smoke/ contains a complete Rust GDExtension project and scene.


Build and Toolchain Setup

Step-by-step instructions for setting up the Emscripten build environment.

1. Install Rust nightly and rust-src

Bash
rustup toolchain install nightly
rustup component add rust-src --toolchain nightly

2. Install the Emscripten SDK

Bash
git clone https://github.com/emscripten-core/emsdk.git
cd emsdk
./emsdk install 3.1.74
./emsdk activate 3.1.74
source ./emsdk_env.sh

Tip

Add source /path/to/emsdk/emsdk_env.sh to your shell profile so the Emscripten toolchain is available in every terminal session.

3. Verify the build

Bash
# Core types only (no transport)
cargo +nightly build -Zbuild-std \
    --target wasm32-unknown-emscripten \
    --no-default-features

# With Emscripten WebSocket transport
cargo +nightly build -Zbuild-std \
    --target wasm32-unknown-emscripten \
    --no-default-features \
    --features transport-websocket-emscripten

Both commands should complete without errors.

4. Verify wasm32-unknown-unknown (optional)

Bash
rustup target add wasm32-unknown-unknown
cargo build --target wasm32-unknown-unknown --no-default-features

CI configuration

The project's CI verifies both WASM targets on every push to main and on every pull request. See .github/workflows/wasm.yml for the full workflow. Key steps:

Job Target Toolchain Features
wasm wasm32-unknown-unknown stable --no-default-features
emscripten wasm32-unknown-emscripten nightly + emsdk 3.1.74 --no-default-features, then --features transport-websocket-emscripten

Feature Flag Reference

Feature Default Description wasm32-unknown-unknown wasm32-unknown-emscripten
transport-websocket Yes WebSocket transport via tokio-tungstenite (TCP sockets) No No
transport-websocket-emscripten No EmscriptenWebSocketTransport; enables polling-client No Yes
token-binding No Native WebSocketTransport support for signalfish.tokenbinding.v2 No No
polling-client No SignalFishPollingClient — sync, polling-based client for any Transport Yes Yes
tokio-runtime Yes (via transport-websocket) Enables tokio/rt and tokio/time for background task spawning No No

Which flags for which target

Target Recommended Cargo features
Native (desktop/server) transport-websocket (default)
wasm32-unknown-unknown --no-default-features (bring your own transport)
Godot 4.5 on wasm32-unknown-emscripten signal-fish-client-godot adapter plus core polling-client
Custom link-enabled Emscripten host --no-default-features --features transport-websocket-emscripten

Feature conflicts

Do not enable transport-websocket or tokio-runtime when targeting any WASM target. They depend on tokio::net::TcpStream and Tokio's multi-threaded runtime, which do not compile to WebAssembly.

Required token-binding deployments are native-only

Browser WebSocket APIs—including Emscripten and Godot's browser-backed WebSocketPeer—do not expose the generated Sec-WebSocket-Key. Those transports therefore cannot derive signalfish.tokenbinding.v2 proofs. Use a server profile where token binding is not required, or connect through a trusted native component that owns the handshake and proof state.


Troubleshooting / FAQ

Missing Emscripten SDK

Error:

Text Only
error: linker `emcc` not found

Solution: Install and activate the Emscripten SDK (version 3.1.74), then source emsdk_env.sh before building. See Build and Toolchain Setup.


Wrong target triple

Error:

Text Only
error[E0432]: unresolved import `crate::transports::emscripten_websocket`

Solution: You are building for wasm32-unknown-unknown with the transport-websocket-emscripten feature enabled. This feature is only available on wasm32-unknown-emscripten. Switch to the correct target:

Bash
cargo +nightly build -Zbuild-std \
    --target wasm32-unknown-emscripten \
    --no-default-features \
    --features transport-websocket-emscripten

Missing rust-src component

Error:

Text Only
error: the `-Zbuild-std` flag requires the `rust-src` component

Solution:

Bash
rustup component add rust-src --toolchain nightly

SignalFishClient::start() does not work with Emscripten

Symptom: The application compiles but hangs or panics at runtime when calling SignalFishClient::start() on wasm32-unknown-emscripten.

Explanation: SignalFishClient::start() requires the tokio-runtime feature to spawn a background task via tokio::spawn. Tokio's runtime is not available on Emscripten. Even if compilation succeeded (e.g., with stubs), the runtime would not function.

Solution: Use SignalFishPollingClient instead. It drives the transport synchronously via poll() and does not require any async runtime:

Rust
let mut client = SignalFishPollingClient::new(transport, config);

// In your game loop:
let events = client.poll();

poll_recv() remains pending on Emscripten

Symptom: Driving transport.poll_recv() from a wake-driven executor hangs indefinitely when the callback queue is empty.

Explanation: When no messages are buffered, EmscriptenWebSocketTransport::poll_recv() returns Poll::Pending without registering the supplied waker. Emscripten callbacks push to a std::sync::mpsc channel that has no waker integration. Debug builds log this misuse once when they observe a non-noop waker.

Solution: Use SignalFishPollingClient::poll(), which calls the transport with a noop waker and correctly handles Pending by breaking out of the receive loop until the next frame.


uuid crate errors on WASM

Symptom: Compilation errors related to uuid::Uuid::new_v4() or random number generation on WASM targets.

Explanation: The uuid crate needs the "js" feature to use getrandom via wasm-bindgen on wasm32 targets.

Solution: This is already handled in the SDK's Cargo.toml:

TOML
[target.'cfg(target_arch = "wasm32")'.dependencies]
uuid = { version = "1", features = ["v4", "serde", "js"] }

No action is needed when using the SDK as a dependency. If you use the uuid crate directly in your own code, add the "js" feature for WASM targets.


MSRV vs. nightly requirement

The framed-transport-agnostic core's MSRV is 1.87.0 for native targets, while the signal-fish-client-godot adapter requires Rust 1.94.0. Regardless of those stable native floors, the wasm32-unknown-emscripten target requires Rust nightly because:

  1. It is a tier 3 target — pre-built std is not available on stable.
  2. The -Zbuild-std flag is a nightly-only feature.

The wasm32-unknown-unknown target works with stable Rust 1.87.0+ (no -Zbuild-std needed; pre-built std is available via rustup target add).


Architecture Deep Dive

End-to-end data flow

This diagram traces a complete round-trip from the Godot game loop through the Emscripten transport to the browser WebSocket and back:

graph LR
    A["Godot _process(delta)"] --> B["poll()"]
    B --> C["Process bounded command work"]
    C --> D["transport.poll_send(frame)"]
    D --> E["emscripten_websocket_send_utf8_text()"]
    E --> F["Browser WebSocket.send()"]

    G["Server"] --> H["Browser WebSocket.onmessage"]
    H --> I["C callback: on_message_callback()"]
    I --> J["mpsc::Sender::send(IncomingEvent::Message)"]
    J --> K["transport.poll_recv() → try_recv()"]
    K --> L["poll() → deserialize → SignalFishEvent"]
    L --> M["Godot match event { ... }"]

The callback bridge pattern

The central design challenge on Emscripten is bridging asynchronous C callbacks (fired by the browser event loop) into synchronous Rust code (called from the game loop). The SDK solves this with a std::sync::mpsc channel:

  1. RegistrationEmscriptenWebSocketTransport::connect() creates a CallbackState struct containing the channel's Sender and passes a raw pointer to Emscripten's callback registration functions.

  2. Callback invocation — when the browser fires a WebSocket event (open, message, error, close), the corresponding extern "C" function converts the raw pointer back to &CallbackState and pushes an IncomingEvent onto the channel.

  3. Consumptionpoll_recv() calls try_recv() on the channel's Receiver. If a message is available, it returns immediately. If the channel is empty, it returns Poll::Pending, which the polling client handles by breaking out of the receive loop.

  4. Cleanuppoll_close, abort, and Drop all attempt native close before socket deletion. A successful deletion unregisters every callback and authorizes reclaiming CallbackState via Box::from_raw. If deletion fails, the state intentionally remains live for a safe retry (or final safety leak), so no callback can dereference freed memory.

sequenceDiagram
    participant Browser as Browser Event Loop
    participant CB as C Callback (extern "C")
    participant CH as std::sync::mpsc Channel
    participant Poll as poll() / try_recv()

    Browser->>CB: WebSocket.onmessage fires
    CB->>CH: tx.send(IncomingEvent::Message(text))
    Note over CH: Message buffered in channel

    Poll->>CH: rx.try_recv()
    CH-->>Poll: Ok(IncomingEvent::Message(text))
    Poll->>Poll: Deserialize to ServerMessage
    Poll->>Poll: Convert to SignalFishEvent

Note

The std::sync::mpsc channel is safe here because wasm32-unknown-emscripten is single-threaded. The "send" from the C callback and the "receive" from poll() never execute concurrently — they interleave on the same thread between browser event loop ticks and game frames.