Events Reference¶
Decoded messages from the Signal Fish server, plus locally generated lifecycle
and diagnostic events, are surfaced as [SignalFishEvent] variants through the
event receiver returned by [SignalFishClient::start].
This page documents all 38 variants grouped by category, with field descriptions and usage examples.
Protocol v2 relay + v3 mesh
Delivery-accountability, relay diagnostics, graceful drain, and mesh events
only arrive on a v3-negotiated connection. Use
SignalFishConfig::enable_v3() for relay/accountability or
enable_mesh() when a WebRTC driver is also available.
See Protocol Versioning and the
Mesh Guide.
Type aliases used throughout
PlayerId is an alias for uuid::Uuid.
RoomId is an alias for uuid::Uuid.
Overflow backpressure and delivery boundaries
Events are not dropped merely because the bounded event channel overflows
(default 256, via SignalFishConfig::event_channel_capacity), and a
consumer that falls behind pauses the transport loop until the channel
has room — backpressure propagates to the server instead of losing
events. Inbound frames that fail to decode surface as
DecodeFailed events rather than being skipped. Delivery
can still stop or be preempted: the event receiver can be dropped,
the client handle can be dropped without calling
shutdown(), which aborts the loop immediately, or
shutdown() can abandon the one delivery currently blocked on channel
capacity. Shutdown attempts a graceful close and delivers the terminal
Disconnected best-effort; its configured deadline may abort that work.
The event channel closing is the authoritative end-of-stream signal.
Connection Events¶
Synthetic events generated by the transport layer rather than the server. Use these to track the raw connection lifecycle.
| Variant | Fields | Description |
|---|---|---|
Connected |
— | The driver first observed Transport::is_ready() == true and marked ClientSnapshot::transport_ready true; a later terminal transition resets it. Synthetic — see Connection timing for details. |
Disconnected |
reason: Option<String>, last_server_error: Option<ServerErrorInfo> |
The transport connection was closed or errored. |
Reconnecting |
attempt: u32, next_backoff: Duration |
A configured ReconnectPolicy scheduled a fresh connection after a retryable disconnect. |
ReconnectAbandoned |
attempts: u32, last_reason: Option<String> |
The configured reconnect policy exhausted its attempts; the event stream ends after this event. |
DecodeFailed |
message_type: Option<String>, error: String, raw_prefix: String |
An inbound frame could not be decoded into a ServerMessage; the connection stays open. |
ProtocolViolation |
kind: ProtocolViolationKind, diagnostic: String |
A decoded message violated lifecycle, version, session-plan, signaling, or delivery-accountability invariants; configured policy decides quarantine, disconnect, or continued observation. Lifecycle/plan/signaling offenders are suppressed. |
Disconnected¶
| Field | Type | Description |
|---|---|---|
reason |
Option<String> |
Human-readable close explanation with structured transport code/reason when available. Servers use semantic codes such as 4000 server_shutdown and 4002 slow_consumer; Server 0.8 also uses 4005 room_inactive. |
last_server_error |
Option<ServerErrorInfo> |
The most recent Error/AuthenticationError received on this connection — a correlation aid for attributing the disconnect. A server that evicts a slow consumer writes a best-effort Error { error_code: SlowConsumer } farewell before closing; when that frame arrives, it shows up here. See the Delivery Contract. |
After outbound send failure, ready server errors can still populate
last_server_error; the exact bounded-drain and cause-precedence rules are in
the Delivery Contract.
Delivery of the terminal Disconnected
During normal operation Disconnected is delivered with backpressure
like every other event — it is not dropped merely because the consumer
is briefly behind. When the connection ends while a
shutdown() is pending (or the handle is dropped),
the terminal Disconnected is delivered best-effort: the transport
loop lets shutdown preempt a delivery blocked on channel capacity, and the
shutdown path attempts the terminal event with non-blocking admission. If
the channel is full, the event may be omitted. The configured shutdown
deadline may invoke the transport's abort path and stop the task before it
reaches that attempt. The event
channel closing (recv()
returns None) is the guaranteed end-of-stream signal — rely on it, not
on always observing a final Disconnected.
Reconnecting / ReconnectAbandoned¶
Emitted only when a
ReconnectPolicy is configured on
the async client. Reconnecting { attempt, next_backoff } precedes each
fresh-transport attempt (1-based attempt, deterministic exponential
next_backoff); a successful attempt continues with a fresh Connected →
Authenticated sequence and, when the client was a player in a room when
the previous connection ended, an automatic directed reconnect.
ReconnectAbandoned {
attempts, last_reason } is the terminal event when the budget runs out;
shutdown, a dropped handle, a ProtocolViolationPolicy::Disconnect
teardown, a peer close whose code the policy classifies terminal
(with_terminal_close_codes), and a farewell the consumer never drained
end the client without attempting reconnection and never produce
these events.
DecodeFailed¶
Emitted when an inbound frame fails to deserialize — an unknown message
type from a newer server, an unknown error_code string inside a known
message, a proxy injecting non-protocol frames, or corruption. The
connection stays open and later frames are unaffected; each occurrence also
increments ClientStats::messages_undecodable.
| Field | Type | Description |
|---|---|---|
message_type |
Option<String> |
The wire type tag when the frame was valid JSON and the failure position allowed tag recovery (frames larger than the 512-byte raw_prefix window report None even when valid JSON). Some("Error") plus a decode failure strongly implies an unknown error_code; None usually means the frame was not valid JSON at all. |
error |
String |
The deserialization error text, capped at 512 bytes (serde quotes hostile wire tokens verbatim, so the text is bounded). |
raw_prefix |
String |
The raw frame, truncated to DECODE_FAILED_RAW_PREFIX_MAX (512) bytes on a UTF-8 boundary. |
Steady growth of messages_undecodable means protocol drift (upgrade this
SDK) or a corrupting middlebox — log DecodeFailed in production builds.
Handle raw_prefix as untrusted text
raw_prefix is verbatim frame content: a hostile server can plant
credential-looking strings in it for exactly the application that logs
decode failures verbatim. Debug formatting redacts it entirely; when
you need the frame's shape for diagnostics, use
redacted_raw_prefix(),
which masks every string literal's content while preserving the JSON
skeleton. Never log raw_prefix verbatim.
ProtocolViolation is distinct from DecodeFailed: its frame decoded, but
its sequence, epoch, lifecycle, gap, counter, or causal state contradicted the
negotiated protocol. Lifecycle-, plan-, and signaling-invalid messages never
mutate client state or reach the application, including under Observe.
Delivery-accountability violations retain Observe's diagnostic delivery
semantics. The default quarantine policy suppresses subsequent room game data
until an authoritative snapshot resets the baseline. See the
Delivery Contract.
match event {
SignalFishEvent::Connected => {
println!("Transport connected — waiting for authentication…");
}
SignalFishEvent::Disconnected { reason, last_server_error } => {
println!(
"Disconnected: {} (last server error: {last_server_error:?})",
reason.as_deref().unwrap_or("unknown"),
);
}
SignalFishEvent::DecodeFailed { message_type, error, .. } => {
eprintln!("undecodable frame (type {message_type:?}): {error}");
}
_ => {}
}
Authentication Events¶
Received in response to the automatic Authenticate message sent when the
client starts. You must wait for Authenticated before sending any other
commands (e.g., joining a room).
| Variant | Key Fields | Description |
|---|---|---|
Authenticated |
app_name: String, organization: Option<String>, rate_limits: RateLimitInfo |
Authentication succeeded. |
ProtocolInfo |
ProtocolInfoPayload (wrapped) |
SDK/protocol compatibility details advertised after authentication. |
AuthenticationError |
error: String, error_code: ErrorCode |
Authentication failed. |
Authenticated¶
| Field | Type | Description |
|---|---|---|
app_name |
String |
Application name confirmed by the server. |
organization |
Option<String> |
Organization the app belongs to, if any. |
rate_limits |
RateLimitInfo |
Rate limits enforced for this application (per_minute, per_hour, per_day). |
ProtocolInfo(ProtocolInfoPayload)¶
The payload is wrapped as a single struct rather than flattened. Important
fields include platform, sdk_version, capabilities, game_data_formats,
player_name_rules, the negotiated protocol version/range, and available
message transports.
AuthenticationError¶
| Field | Type | Description |
|---|---|---|
error |
String |
Human-readable error description. |
error_code |
ErrorCode |
Structured error code for programmatic handling. |
match event {
SignalFishEvent::Authenticated { app_name, rate_limits, .. } => {
println!("Authenticated as {app_name}");
println!("Rate limits: {}/min", rate_limits.per_minute);
}
SignalFishEvent::ProtocolInfo(info) => {
println!("Capabilities: {:?}", info.capabilities);
}
SignalFishEvent::AuthenticationError { error, error_code } => {
eprintln!("Auth failed [{error_code}]: {error}");
}
_ => {}
}
Room Events¶
Events related to joining, failing to join, or leaving a room.
| Variant | Key Fields | Description |
|---|---|---|
RoomJoined |
room_id, room_code, player_id, current_players, … |
Successfully joined a room. |
RoomJoinFailed |
reason: String, error_code: Option<ErrorCode> |
Failed to join a room. |
RoomLeft |
— | Successfully left the current room. |
RoomOperationFailed |
reason: String, error_code: Option<ErrorCode> |
A UUID-correlated room operation failed without an operation-specific terminal result. The exact pending fence is released. |
RoomJoined¶
| Field | Type | Description |
|---|---|---|
room_id |
RoomId |
Unique room identifier. |
room_code |
String |
Human-readable room code. |
player_id |
PlayerId |
The local player's identifier. |
game_name |
String |
Name of the game this room is for. |
max_players |
u8 |
Maximum number of players allowed. u8 ceilings the model at 255: a server advertising a larger capacity fails the frame as DecodeFailed rather than truncating the value. |
supports_authority |
bool |
Whether the room supports authority delegation. |
current_players |
Vec<PlayerInfo> |
Players already present in the room. |
is_authority |
bool |
Whether the local player is the authority. |
lobby_state |
LobbyState |
Current lobby readiness state (Waiting, Lobby, or Finalized). |
ready_players |
Vec<PlayerId> |
Players that have signaled readiness. |
relay_type |
String |
Legacy deployment relay label; Server 0.8 uses it as protocol metadata, not proof of a physical path. |
current_spectators |
Vec<SpectatorInfo> |
Spectators currently watching. |
ice_servers |
Vec<IceServer> |
Protocol-v3 STUN/TURN servers for early candidate gathering; empty on the v2 floor. |
reconnection_token |
Option<String> |
Server-issued v3 secret retained by ClientSnapshot for unexpected-disconnect recovery. |
Scripted-peer contract for the join snapshot
The client validates the authoritative snapshot before surfacing it.
A mock server driving this event must satisfy all of the following, or the
frame is quarantined (or rejected under Disconnect policy):
- the local
player_idappears incurrent_playersexactly once; - on a negotiated v3 connection, every
PlayerInfocarries pairedepochandseqvalues (both present or both absent — a mixed pair is a violation, despite the fields being optional in isolation); - on a v2 connection, no
PlayerInfomay carryepoch/seqat all (a "v2 snapshot exposed delivery baseline" violation).
RoomJoinFailed¶
| Field | Type | Description |
|---|---|---|
reason |
String |
Human-readable failure reason. |
error_code |
Option<ErrorCode> |
Structured error code, if provided. |
match event {
SignalFishEvent::RoomJoined { room_code, player_id, current_players, .. } => {
println!("Joined room {room_code} as {player_id}");
println!("{} player(s) already here", current_players.len());
}
SignalFishEvent::RoomJoinFailed { reason, error_code } => {
eprintln!("Join failed: {reason} ({error_code:?})");
}
SignalFishEvent::RoomLeft => {
println!("Left the room");
}
SignalFishEvent::RoomOperationFailed { reason, error_code } => {
eprintln!("Room operation failed: {reason} ({error_code:?})");
}
_ => {}
}
Player Events¶
Notifications about other players joining or leaving the room you are in.
| Variant | Fields | Description |
|---|---|---|
PlayerJoined |
player: PlayerInfo |
Another player joined the room. |
PlayerLeft |
player_id: PlayerId, epoch: Option<u32>, final_seq: Option<u64> |
Another player left; v3 fields identify the incarnation and terminal relay watermark. |
PlayerInfo contains id, name, is_authority, is_ready,
connected_at, optional connection_info, and optional protocol-v3 epoch
and seq snapshot metadata. connected_at reads back as an empty string on
protocol-v3 snapshots, which trim the server-internal join timestamp for
privacy (signal-fish-server #539).
match event {
SignalFishEvent::PlayerJoined { player } => {
println!("{} joined (id: {})", player.name, player.id);
}
SignalFishEvent::PlayerLeft { player_id, .. } => {
println!("Player {player_id} left");
}
_ => {}
}
Game Data Events¶
Carry arbitrary payloads between players. JSON payloads arrive as
GameData; binary-encoded payloads (MessagePack, Rkyv) arrive as
GameDataBinary.
| Variant | Fields | Description |
|---|---|---|
GameData |
from_player, data, seq, epoch, class, key |
JSON game data plus optional v3 delivery stamp and classification. |
GameDataBinary |
from_player, encoding, payload, seq, epoch |
Binary data from a strict physical envelope; v2 has no stamps, while v3 requires both. |
GameDataEncoding is one of Json, MessagePack, or Rkyv.
Debug formatting for SignalFishEvent intentionally prints only the variant
name. Events can contain reconnect credentials and arbitrary application data;
pattern-match fields explicitly instead of logging the whole event.
match event {
SignalFishEvent::GameData { from_player, data, .. } => {
println!("JSON data from {from_player}: {data}");
}
SignalFishEvent::GameDataBinary { from_player, encoding, payload, .. } => {
println!(
"Binary data from {from_player}: {encoding:?}, {} bytes",
payload.len()
);
}
_ => {}
}
Authority Events¶
Authority delegation lets one player act as the game host. These events report changes and responses to authority requests.
| Variant | Key Fields | Description |
|---|---|---|
AuthorityChanged |
authority_player: Option<PlayerId>, you_are_authority: bool |
The room's authority assignment changed. |
AuthorityResponse |
granted: bool, reason: Option<String>, error_code: Option<ErrorCode> |
Response to an authority request. |
match event {
SignalFishEvent::AuthorityChanged { authority_player, you_are_authority } => {
if you_are_authority {
println!("You are now the authority");
} else if let Some(id) = authority_player {
println!("Authority is now player {id}");
}
}
SignalFishEvent::AuthorityResponse { granted, reason, .. } => {
if granted {
println!("Authority request granted");
} else {
println!("Authority denied: {}", reason.as_deref().unwrap_or("no reason"));
}
}
_ => {}
}
Lobby Events¶
Lobby state tracks player readiness. Readiness alone does not finalize the
lobby: after all_ready becomes true, an eligible client must call
client.start_game(). In an authority-enabled room, only the current authority
is eligible. A successful request produces GameStarting with peer connection
details.
| Variant | Key Fields | Description |
|---|---|---|
LobbyStateChanged |
lobby_state: LobbyState, ready_players: Vec<PlayerId>, all_ready: bool |
The lobby readiness state changed. |
GameStarting |
peer_connections: Vec<PeerConnectionInfo> |
The game is starting with peer connection info. |
LobbyState is one of Waiting, Lobby, or Finalized.
PeerConnectionInfo contains player_id, player_name, is_authority,
relay_type, and an optional connection_info.
match event {
SignalFishEvent::LobbyStateChanged { lobby_state, ready_players, all_ready } => {
println!("Lobby: {lobby_state:?}, {} player(s) ready{}", ready_players.len(),
if all_ready { " (all ready)" } else { "" });
}
SignalFishEvent::GameStarting { peer_connections } => {
println!("Game starting with {} peers", peer_connections.len());
for peer in &peer_connections {
println!(" {} (authority={})", peer.player_name, peer.is_authority);
}
}
_ => {}
}
Do not call start_game() on every repeated ready-state update. Keep a
one-shot request latch, and in authority-enabled rooms re-evaluate eligibility
when AuthorityChanged arrives. The compiling
basic_lobby example
shows that complete pattern.
Mesh Events (protocol v3)¶
v3-negotiated connections only
These four events arrive only when the connection has negotiated protocol v3 and the server emits the corresponding plan/signaling state. A v2 relay-floor connection never emits them. See the Mesh Guide for the full peer-to-peer flow and Protocol Versioning for how negotiation works.
These events carry the server's WebRTC mesh signaling. The server is
authoritative: it chooses the topology and assigns the deterministic WebRTC
offerer via the initiate / you_initiate flags, which you must obey verbatim
(never compute who offers — that avoids WebRTC glare).
| Variant | Key Fields | Description |
|---|---|---|
SessionPlan |
generation, topology, transport, host, direct_endpoint, peers, ice_servers, fallback |
The server's per-recipient authoritative session plan. |
NewPeer |
peer_id: PlayerId, you_initiate: bool |
A late-joining peer to connect to after the session was finalized. |
SignalReceived |
from: PlayerId, generation, signal: serde_json::Value |
A generation-validated opaque WebRTC signal relayed from a peer. |
PeerTransportStatus |
peer_id: PlayerId, transport: TransportKind, connected: bool |
A peer's data-path transport state changed (informational). |
SessionPlan¶
May arrive multiple times (host re-election, late-join re-plan); each one fully replaces the previous plan — replace the peer set, never merge.
| Field | Type | Description |
|---|---|---|
generation |
Option<SessionGeneration> |
Handshake generation. Some on server 0.7+; None only for legacy generation-less plans. |
topology |
Topology |
Chosen session topology (Relay, Host, or Mesh). |
transport |
TransportKind |
Chosen data-path transport (Relay, Direct, or WebRtc). |
host |
Option<PlayerId> |
The elected host (present for Host topology). |
direct_endpoint |
Option<DirectEndpoint> |
Validated endpoint for Host + Direct; opening the socket remains the application's responsibility. |
peers |
Vec<SessionPeer> |
Peers this client should connect to, each with its server-assigned initiate flag. |
ice_servers |
Vec<IceServer> |
ICE (STUN/TURN) servers for WebRTC. |
fallback |
TransportKind |
The universal fallback transport (always Relay). |
SignalReceived¶
Convert the opaque signal value with PeerSignal::try_from(&signal) for the
common Offer / Answer / IceCandidate shapes; the raw Value is preserved
for any other shape.
use std::collections::HashSet;
use signal_fish_client::{PeerSignal, PlayerId, TransportKind};
let mut current_generation = None;
let mut webrtc_plan_active = false;
let mut connected_peers = HashSet::<PlayerId>::new();
match event {
SignalFishEvent::SessionPlan {
generation, topology, transport, peers, ice_servers, ..
} => {
// Every plan replaces the previous physical graph, including Direct
// and Relay reset plans.
for peer in connected_peers.drain() {
my_driver.disconnect(peer);
}
webrtc_plan_active = transport == TransportKind::WebRtc;
current_generation = webrtc_plan_active.then_some(generation).flatten();
println!("Session plan: {topology:?} with {} peer(s)", peers.len());
if webrtc_plan_active {
my_driver.set_ice_servers(&ice_servers);
for peer in &peers {
connected_peers.insert(peer.player_id);
my_driver.connect(peer.player_id, generation, peer.initiate);
}
}
}
SignalFishEvent::NewPeer { peer_id, you_initiate } if webrtc_plan_active => {
connected_peers.insert(peer_id);
my_driver.connect(peer_id, current_generation, you_initiate);
}
SignalFishEvent::SignalReceived { from, generation, signal } => {
if webrtc_plan_active
&& generation == current_generation
&& connected_peers.contains(&from)
{
if let Ok(peer_signal) = PeerSignal::try_from(&signal) {
my_driver.on_signal(from, generation, peer_signal);
}
}
}
SignalFishEvent::PeerTransportStatus { peer_id, connected, .. } => {
println!("Peer {peer_id} transport connected={connected}");
}
_ => {}
}
Let the SDK do the choreography
With the mesh feature, MeshController
handles all four of these events for you — calling your WebRtcDriver,
relaying signals, and reporting transport status — so you rarely match them
by hand. See the Mesh Guide.
Delivery and Drain Events (protocol v3)¶
| Variant | Fields | Description |
|---|---|---|
DeliveryReport(payload) |
DeliveryReportPayload |
Cumulative per-class outcomes and exact omitted sequence ranges. Exact ranges are the only authorization for continuing-connection gaps. |
RelayStats |
interval_ms, sent_to_you, dropped_for_you, backpressure_events |
Optional cumulative connection diagnostics; never gap authorization. Counters are cumulative since this connection registered with the relay (they survive reconnection); the SDK validates the authority's invariants (positive constant interval, monotonic counters) and reports a violating frame as ProtocolViolation { Counters }. A rising backpressure_events predicts a 4002 slow_consumer eviction. |
GoingAway |
deadline_ms, retry_after_secs |
Best-effort graceful-drain advisory. deadline_ms is an absolute Unix epoch timestamp in milliseconds; retry_after_secs is an operator hint in seconds. The subsequent structured transport close remains authoritative. |
Applications normally let the SDK consume these for accountability and also
record the typed events for telemetry. GoingAway is a cue to preserve the
current reconnect snapshot and prepare a retry after the advertised delay.
Heartbeat Events¶
Call client.ping() to send a heartbeat message that keeps the connection alive.
The server replies with a Pong event confirming receipt.
| Variant | Fields | Description |
|---|---|---|
Pong |
— | Connection-scoped response to a ping; valid while authentication or protocol negotiation is still in flight. |
match event {
SignalFishEvent::Pong => {
// Connection is healthy — usually no action needed.
}
_ => {}
}
Reconnection Events¶
If a player's connection drops, the SDK can attempt to rejoin the same room. On success the server replays any events that were missed.
| Variant | Key Fields | Description |
|---|---|---|
Reconnected |
room_id, room_code, player_id, missed_events, … |
Reconnection succeeded; state is restored. |
ReconnectionFailed |
reason: String, error_code: ErrorCode |
Reconnection failed. |
PlayerReconnected |
player_id: PlayerId, epoch: Option<u32> |
Another player reconnected; v3 carries the new incarnation epoch. |
Reconnected¶
Carries the same room-state fields as RoomJoined plus:
| Field | Type | Description |
|---|---|---|
missed_events |
Vec<SignalFishEvent> |
Events that occurred while the client was disconnected. |
replay |
Option<ReplayStatus> |
Completeness of the replayed control-event suffix: Complete (nothing was lost), Truncated (some history was dropped — reconcile from sender_watermarks and your own game state), or Unavailable (the server could not replay — do not rely on missed_events). None means the connected server predates replay reporting (pre-v3 negotiation). |
sender_watermarks |
Vec<SenderWatermark> |
Authoritative per-sender epoch/sequence baselines for resumed delivery. The SDK validates them against its accountability state; they also let you judge which remote inputs your simulation already applied before the gap. |
ice_servers |
Vec<IceServer> |
Protocol-v3 STUN/TURN servers for early candidate gathering. |
reconnection_token |
Option<String> |
Fresh secret replacing the consumed token; also stored in ClientSnapshot. |
ReconnectionFailed¶
| Field | Type | Description |
|---|---|---|
reason |
String |
Human-readable failure reason. |
error_code |
ErrorCode |
Structured error code. |
match event {
SignalFishEvent::Reconnected { room_code, missed_events, .. } => {
println!("Reconnected to {room_code}");
println!("Replaying {} missed events", missed_events.len());
for missed in &missed_events {
println!(" missed: {missed:?}");
}
}
SignalFishEvent::ReconnectionFailed { reason, error_code } => {
eprintln!("Reconnection failed [{error_code}]: {reason}");
}
SignalFishEvent::PlayerReconnected { player_id, .. } => {
println!("Player {player_id} reconnected");
}
_ => {}
}
Spectator Events¶
Spectators can watch a room without participating. These events cover the full spectator lifecycle.
| Variant | Key Fields | Description |
|---|---|---|
SpectatorJoined |
room_id, spectator_id, current_players, current_spectators, … |
Successfully joined a room as a spectator. |
SpectatorJoinFailed |
reason: String, error_code: Option<ErrorCode> |
Failed to join as a spectator. |
SpectatorLeft |
room_id: Option<RoomId>, room_code: Option<String>, reason, current_spectators |
The spectator left the room: either the answer to this client's admitted voluntary leave, or an authoritative exit (server removal, spectator disconnect, or room close). |
NewSpectatorJoined |
spectator: SpectatorInfo, current_spectators, reason, spectator_count: Option<u32> |
Another spectator joined the room. spectator_count carries the room total on servers with the v3 spectator fan-out slimming tier (where current_spectators is an empty delta) and is None on the full-roster face. |
SpectatorDisconnected |
spectator_id: PlayerId, reason, current_spectators, spectator_count: Option<u32> |
Another spectator disconnected. spectator_count carries the room total on servers with the v3 spectator fan-out slimming tier and is None on the full-roster face. |
SpectatorJoined¶
| Field | Type | Description |
|---|---|---|
room_id |
RoomId |
Unique room identifier. |
room_code |
String |
Human-readable room code. |
spectator_id |
PlayerId |
The local spectator's identifier. |
game_name |
String |
Name of the game this room is for. |
current_players |
Vec<PlayerInfo> |
Players currently in the room. |
current_spectators |
Vec<SpectatorInfo> |
Spectators currently watching. |
lobby_state |
LobbyState |
Current lobby readiness state. |
reason |
Option<SpectatorStateChangeReason> |
Reason the spectator state changed, if applicable. |
SpectatorLeft¶
| Field | Type | Description |
|---|---|---|
room_id |
Option<RoomId> |
Room identifier, if available. |
room_code |
Option<String> |
Room code, if available. |
reason |
Option<SpectatorStateChangeReason> |
Reason for leaving, if available. |
current_spectators |
Vec<SpectatorInfo> |
Remaining spectators in the room. |
SpectatorStateChangeReason is one of Joined, VoluntaryLeave,
Disconnected, Removed, or RoomClosed.
match event {
SignalFishEvent::SpectatorJoined { room_code, spectator_id, current_players, .. } => {
println!("Spectating room {room_code} as {spectator_id}");
println!("{} player(s) in game", current_players.len());
}
SignalFishEvent::SpectatorJoinFailed { reason, error_code } => {
eprintln!("Spectator join failed: {reason} ({error_code:?})");
}
SignalFishEvent::SpectatorLeft { reason, .. } => {
println!("Left spectator mode: {reason:?}");
}
SignalFishEvent::NewSpectatorJoined { spectator, current_spectators, .. } => {
println!("{} started spectating ({} total)",
spectator.name, current_spectators.len());
}
SignalFishEvent::SpectatorDisconnected { spectator_id, current_spectators, .. } => {
println!("Spectator {spectator_id} left ({} remaining)",
current_spectators.len());
}
_ => {}
}
Error Events¶
Catch-all for server-side errors that don't fit a more specific variant.
| Variant | Fields | Description |
|---|---|---|
Error |
message: String, error_code: Option<ErrorCode> |
A generic server error. |
match event {
SignalFishEvent::Error { message, error_code } => {
eprintln!("Server error: {message} ({error_code:?})");
}
_ => {}
}
Complete Event Loop Pattern¶
A realistic event loop handling the most important variants. Use this as a starting template and expand as needed for your game.
use signal_fish_client::{
JoinRoomParams, SignalFishClient, SignalFishConfig,
SignalFishEvent, WebSocketTransport,
};
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let url = std::env::var("SIGNAL_FISH_URL")
.unwrap_or_else(|_| "ws://localhost:3536/v2/ws".to_string());
let transport = WebSocketTransport::connect(&url).await?;
let config = SignalFishConfig::new("your-app-id");
let (mut client, mut event_rx) = SignalFishClient::start(transport, config);
let mut start_request_sent = false;
while let Some(event) = event_rx.recv().await {
match event {
// ── Connection ──────────────────────────────────────
SignalFishEvent::Connected => {
println!("Connected — authenticating…");
}
// ── Authentication ──────────────────────────────────
SignalFishEvent::Authenticated { app_name, .. } => {
println!("Authenticated as {app_name}");
client.join_room(JoinRoomParams::new("my-game", "Alice"))?;
}
SignalFishEvent::AuthenticationError { error, error_code } => {
eprintln!("Auth failed [{error_code}]: {error}");
break;
}
// ── Room lifecycle ──────────────────────────────────
SignalFishEvent::RoomJoined { room_code, current_players, .. } => {
println!("Joined room {room_code}");
println!("{} player(s) in room", current_players.len());
client.set_ready()?;
}
// ── Player presence ─────────────────────────────────
SignalFishEvent::PlayerJoined { player } => {
println!("{} joined the room", player.name);
}
SignalFishEvent::PlayerLeft { player_id, .. } => {
println!("Player {player_id} left");
}
// ── Game data ───────────────────────────────────────
SignalFishEvent::GameData { from_player, data, .. } => {
println!("Data from {from_player}: {data}");
}
// ── Lobby ───────────────────────────────────────────
SignalFishEvent::LobbyStateChanged { all_ready, .. } => {
if all_ready && !start_request_sent {
println!("All players ready!");
// This example creates a non-authority room. Authority-enabled
// rooms must additionally require that this client is authority.
client.start_game()?;
start_request_sent = true;
}
}
SignalFishEvent::GameStarting { peer_connections } => {
println!("Game starting with {} peers!", peer_connections.len());
}
// ── Errors ──────────────────────────────────────────
SignalFishEvent::Error { message, error_code } => {
eprintln!("Server error: {message} ({error_code:?})");
}
// ── Disconnection ───────────────────────────────────
SignalFishEvent::Disconnected { reason, .. } => {
println!("Disconnected: {}",
reason.as_deref().unwrap_or("unknown"));
break;
}
// ── Everything else ─────────────────────────────────
_ => {}
}
}
client.shutdown().await;
Ok(())
}
Tip
All public enums in this crate are exhaustive. This introductory loop uses
_ => {} to focus on common events. Production code can enumerate every
variant and omit the catch-all when it wants compiler errors to surface new
variants during a breaking-version upgrade.