Core Concepts¶
This page explains the foundational ideas behind the Signal Fish Client SDK. Understanding these concepts will help you use the SDK effectively and debug issues when they arise.
Transport-Agnostic Design¶
The SDK separates networking from client logic through the Transport
trait. SignalFishClient never knows (or cares) whether it is talking over a
WebSocket, a raw TCP socket, a QUIC stream, or even an in-memory test loopback.
graph LR
A["Transport (trait)"] --> B["SignalFishClient"]
B --> C["SignalFishEvent (mpsc channel)"]
The Transport trait defines three async methods — send, receive, and close:
#[async_trait]
pub trait Transport: Send + 'static {
async fn send(&mut self, message: String) -> Result<(), SignalFishError>;
async fn recv(&mut self) -> Option<Result<String, SignalFishError>>;
async fn close(&mut self) -> Result<(), SignalFishError>;
}
| Method | Purpose |
|---|---|
send |
Transmit one serialized JSON message to the server. |
recv |
Receive the next JSON message. Returns None on clean close. Must be cancel-safe. |
close |
Gracefully shut down the underlying connection. |
Bring your own transport
Connection setup is intentionally not part of the trait. Different
transports have different connection parameters (URLs, host:port, QUIC
endpoints, etc.). Construct a connected transport externally, then hand it
to SignalFishClient::start.
The crate ships with a ready-made WebSocketTransport (behind the transport-websocket
feature flag), but you can implement the trait for any medium.
Client Lifecycle¶
SignalFishClient follows a linear state machine. Every session progresses
through the same states:
stateDiagram-v2
[*] --> Disconnected
Disconnected --> Connected : Transport opens
Connected --> Authenticated : Server confirms auth
Authenticated --> InRoom : join_room / join_as_spectator
InRoom --> Authenticated : leave_room / leave_spectator
Authenticated --> Disconnected : shutdown / error
InRoom --> Disconnected : shutdown / error
Connected --> Disconnected : auth failure / error
| Transition | Trigger |
|---|---|
| Disconnected → Connected | SignalFishClient::start spawns the background task and emits SignalFishEvent::Connected. |
| Connected → Authenticated | The SDK auto-sends an Authenticate message. On success the server replies and SignalFishEvent::Authenticated is emitted. |
| Authenticated → InRoom | Call client.join_room(params) or client.join_as_spectator(...). The server responds with SignalFishEvent::RoomJoined (or SpectatorJoined). |
| InRoom → Authenticated | Call client.leave_room() or client.leave_spectator(). The server confirms with SignalFishEvent::RoomLeft. |
| Any → Disconnected | Call client.shutdown().await, drop the client, or encounter an unrecoverable transport error. SignalFishEvent::Disconnected is the final event (best-effort; see Events for delivery caveats). |
Authentication is automatic
You do not need to call an authenticate method. SignalFishClient::start
sends the authentication message immediately using the SignalFishConfig
you provide.
Event-Driven Architecture¶
All server responses arrive as SignalFishEvent variants on a bounded
mpsc::Receiver<SignalFishEvent> (default capacity 256, configurable via
SignalFishConfig::event_channel_capacity). Your application consumes
them in an async loop:
let config = SignalFishConfig::new("mb_app_abc123");
let (client, mut events) = SignalFishClient::start(transport, config);
while let Some(event) = events.recv().await {
match event {
SignalFishEvent::Connected => {
println!("Transport connected, awaiting auth…");
}
SignalFishEvent::Authenticated { app_name, .. } => {
println!("Authenticated as {app_name}");
}
SignalFishEvent::RoomJoined { room_code, current_players, .. } => {
println!("Joined room {room_code} with {} players", current_players.len());
}
SignalFishEvent::Disconnected { reason } => {
println!("Disconnected: {reason:?}");
break;
}
_ => {}
}
}
Synthetic vs. Server Events¶
Most events correspond 1:1 to a server message. Two synthetic events are generated locally by the transport layer:
| Event | Origin |
|---|---|
SignalFishEvent::Connected |
Emitted when the transport opens, before any server message. |
SignalFishEvent::Disconnected { reason } |
Emitted when the transport closes or errors. Last event (best-effort). |
Channel capacity
The event channel has a default capacity of 256 (configurable via
SignalFishConfig::event_channel_capacity). If your consumer falls behind,
events are dropped (with a warning logged) to avoid blocking the
transport loop. The Disconnected event is the exception — it uses a
blocking send so it will not be dropped due to backpressure, but it may be
missed if the receiver is dropped or shutdown times out (see
Events). Design your event loop to stay responsive to avoid
losing events.
Non-Blocking Command Sending¶
All client command methods — join_room, leave_room, send_game_data,
set_ready, request_authority, provide_connection_info, reconnect,
join_as_spectator, leave_spectator, ping — are synchronous. They
serialize a ClientMessage, queue it on an internal unbounded channel, and
return Result<()> immediately. There is no .await.
// These return instantly — no network round-trip
client.join_room(
JoinRoomParams::new("my-game", "Alice")
.with_max_players(4),
)?;
client.send_game_data(serde_json::json!({ "action": "move", "x": 10 }))?;
client.set_ready()?;
Besides the state accessors, the only async method on the client is shutdown():
State Accessors¶
| Accessor | Async? | Returns |
|---|---|---|
is_connected() |
No | bool |
is_authenticated() |
No | bool |
current_player_id() |
Yes (async) |
Option<PlayerId> |
current_room_id() |
Yes (async) |
Option<RoomId> |
current_room_code() |
Yes (async) |
Option<String> |
The synchronous accessors use AtomicBool internally. The async accessors use
a tokio::sync::Mutex because they guard heap-allocated optional state.
State Management¶
The SDK maintains internal state that is updated by the background transport loop as server messages arrive:
| Field | Type | Updated when |
|---|---|---|
connected |
AtomicBool |
Transport opens / closes |
authenticated |
AtomicBool |
Authenticated event received |
player_id |
Mutex<Option<PlayerId>> |
RoomJoined / Reconnected / SpectatorJoined |
room_id |
Mutex<Option<RoomId>> |
RoomJoined / RoomLeft / Reconnected / SpectatorJoined / SpectatorLeft |
room_code |
Mutex<Option<String>> |
RoomJoined / RoomLeft / Reconnected / SpectatorJoined / SpectatorLeft |
State flows one direction: the background task writes, your code reads through the accessors. You never set state directly.
graph LR
S["Server messages"] --> T["Background task"]
T --> St["Shared state"]
T --> E["Event channel"]
St --> A["Accessor methods"]
E --> U["Your event loop"]
Note
State updates happen before the corresponding event is emitted on the
channel. By the time you receive SignalFishEvent::RoomJoined,
client.current_room_id().await already returns Some(...).
Graceful Shutdown¶
To stop the client cleanly, call shutdown():
Under the hood this:
- Sends a signal to the background transport loop via a
oneshotchannel. - The loop calls
transport.close()and emitsSignalFishEvent::Disconnected. shutdown()awaits the background task with a configurable timeout (default 1 second, set viaSignalFishConfig::shutdown_timeout). If the task does not finish in time, it is aborted to prevent detached background work.- On completion, client session state is reset even if the
Disconnectedevent was not delivered due to timeout/abort.
Drop Fallback¶
If shutdown() is never called and the SignalFishClient is dropped, the
Drop implementation aborts the background task immediately. This is a
last-resort cleanup — always prefer an explicit shutdown().await so that the
server receives a clean close and Disconnected is emitted.
Warning
Drop cannot run async code. It calls task.abort(), which cancels the
future without executing transport.close(). The server may see an
unclean disconnection.
Error Handling Model¶
Errors are split into two layers depending on where they originate.
Client-Side: SignalFishError¶
SignalFishError covers transport and local failures. These are returned
directly from client methods as Result<(), SignalFishError>.
| Variant | Meaning |
|---|---|
TransportSend(String) |
Failed to write to the transport. |
TransportReceive(String) |
Failed to read from the transport. |
TransportClosed |
The transport connection closed unexpectedly. |
Serialization(serde_json::Error) |
JSON serialization / deserialization failed. |
NotConnected |
Attempted an operation without an active connection. |
NotInRoom |
Attempted a room operation without being in a room. |
ServerError { message, error_code } |
The server returned an error; error_code is Option<ErrorCode> and may be absent. |
Timeout |
An operation exceeded its time limit. |
Io(std::io::Error) |
An underlying I/O error occurred. |
Server-Side: ErrorCode¶
ErrorCode is a 40-variant enum that arrives inside events. The server sends
these as SCREAMING_SNAKE_CASE strings (e.g., "ROOM_NOT_FOUND").
match event {
SignalFishEvent::Error { message, error_code } => {
println!("Server error: {message} ({error_code:?})");
}
SignalFishEvent::AuthenticationError { error, error_code } => {
println!("Auth failed: {error} ({})", error_code.description());
}
_ => {}
}
Error codes are grouped by category:
| Category | Examples |
|---|---|
| Authentication | Unauthorized, InvalidAppId, AppIdExpired, SdkVersionUnsupported |
| Validation | InvalidInput, InvalidGameName, InvalidPlayerName, MessageTooLarge |
| Room | RoomNotFound, RoomFull, AlreadyInRoom, NotInRoom |
| Authority | AuthorityNotSupported, AuthorityConflict, AuthorityDenied |
| Rate Limiting | RateLimitExceeded, TooManyConnections |
| Reconnection | ReconnectionFailed, ReconnectionTokenInvalid, ReconnectionExpired |
| Spectator | SpectatorNotAllowed, TooManySpectators, SpectatorJoinFailed |
| Server | InternalError, StorageError, ServiceUnavailable |
Programmatic handling
Every ErrorCode variant has a .description() method that returns a
human-readable explanation. Use the enum variant for match-based control
flow and the description for user-facing messages.