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
Transporttrait definition SignalFishConfigandJoinRoomParamsbuildersSignalFishEventandSignalFishErrorenums
What you do not get¶
- No built-in transport (WebSocket over TCP is unavailable in the browser sandbox)
- No Tokio runtime (
tokio::net::TcpStreamdoes not compile to WASM) - No
SignalFishClient::start()(requirestokio::spawn)
Building¶
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/webWebSocketPeerwrapper- Optionally,
EmscriptenWebSocketTransportfor 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:
[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:
[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¶
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¶
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:
- 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. - Each callback pushes an
IncomingEventonto astd::sync::mpsc::Sender. - When
poll_recv()is called, it callstry_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::Pendingwithout registering the supplied waker.
- If a message is available, it returns
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¶
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:
// 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:
- Creates a
std::task::Contextwith a noop waker. - Pops queued commands up to both the send frame and byte budgets, serializes
each, and polls
transport.poll_send.Pendingbefore ownership transfer preserves the exact frame for the next cycle. - Loops calling
transport.poll_recv(). Each ready frame is decoded and converted to aSignalFishEvent, and appended to the output vector. The loop breaks onPending(no more buffered messages this frame) orReady(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¶
[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¶
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¶
ready()— creates the Godot WebSocket transport and the polling client. Authentication is queued automatically.process(delta)— called every frame by Godot. Callspoll()to flush outgoing messages and drain incoming events. Each event is handled inline.- Disconnection — call
client.close()and keep processing frames whileclient.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¶
2. Install the Emscripten SDK¶
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¶
# 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)¶
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:
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:
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:
cargo +nightly build -Zbuild-std \
--target wasm32-unknown-emscripten \
--no-default-features \
--features transport-websocket-emscripten
Missing rust-src component¶
Error:
Solution:
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:
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:
[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:
- It is a tier 3 target — pre-built
stdis not available on stable. - The
-Zbuild-stdflag 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:
-
Registration —
EmscriptenWebSocketTransport::connect()creates aCallbackStatestruct containing the channel'sSenderand passes a raw pointer to Emscripten's callback registration functions. -
Callback invocation — when the browser fires a WebSocket event (open, message, error, close), the corresponding
extern "C"function converts the raw pointer back to&CallbackStateand pushes anIncomingEventonto the channel. -
Consumption —
poll_recv()callstry_recv()on the channel'sReceiver. If a message is available, it returns immediately. If the channel is empty, it returnsPoll::Pending, which the polling client handles by breaking out of the receive loop. -
Cleanup —
poll_close,abort, andDropall attempt native close before socket deletion. A successful deletion unregisters every callback and authorizes reclaimingCallbackStateviaBox::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.