Quick Start¶
Get Signal Fish Server running and two clients connected in under 5 minutes.
Prerequisites¶
You need one of the following:
- Rust 1.88+ -- to build and run from source
- Docker -- to run the pre-built container image
Step 1: Start the Server¶
The server starts on port 3536 by default. Verify it is running:
Step 2: Connect and Create a Room¶
Open a new terminal and create a Rust project for your first client.
Add the following dependencies to your Cargo.toml:
[dependencies]
tokio = { version = "1", features = ["rt-multi-thread", "macros"] }
tokio-tungstenite = "0.28"
futures-util = "0.3"
serde_json = "1.0"
serde = { version = "1.0", features = ["derive"] }
Create a file called src/main.rs with the following code. This client
connects to the server and creates a new room by sending a JoinRoom
message without a room_code:
use futures_util::{SinkExt, StreamExt};
use serde_json::Value;
use tokio_tungstenite::connect_async;
use tokio_tungstenite::tungstenite::Message;
#[tokio::main]
async fn main() {
let url = "ws://localhost:3536/v2/ws";
let (mut ws, _) = connect_async(url)
.await
.expect("Failed to connect");
println!("Connected to Signal Fish Server");
// Create a new room (no room_code means "create")
let join_msg = serde_json::json!({
"type": "JoinRoom",
"data": {
"game_name": "my-game",
"player_name": "Player1",
"max_players": 2
}
});
ws.send(Message::Text(join_msg.to_string().into()))
.await
.expect("Failed to send JoinRoom");
// Read the RoomJoined response
if let Some(Ok(Message::Text(text))) = ws.next().await {
let response: Value = serde_json::from_str(&text)
.expect("Invalid JSON from server");
let msg_type = response["type"].as_str().unwrap_or("unknown");
let room_code = response["data"]["room_code"]
.as_str()
.unwrap_or("none");
println!("Response type: {msg_type}");
println!("Room code: {room_code}");
println!("Share this room code with another player!");
}
}
Run this client:
You should see output like:
Connected to Signal Fish Server
Response type: RoomJoined
Room code: A7X2K9
Share this room code with another player!
Copy the room code from the output. You will need it in the next step.
Step 3: Join from Another Client¶
In a separate terminal, create a second Rust project. Use the same
Cargo.toml dependencies as Step 2, then create src/main.rs with
the following code. Replace A7X2K9 with the room code from Step 2:
use futures_util::{SinkExt, StreamExt};
use serde_json::Value;
use tokio_tungstenite::connect_async;
use tokio_tungstenite::tungstenite::Message;
#[tokio::main]
async fn main() {
let url = "ws://localhost:3536/v2/ws";
let (mut ws, _) = connect_async(url)
.await
.expect("Failed to connect");
println!("Connected to Signal Fish Server");
// Join the existing room using the room code from Step 2
let join_msg = serde_json::json!({
"type": "JoinRoom",
"data": {
"game_name": "my-game",
"room_code": "A7X2K9",
"player_name": "Player2"
}
});
ws.send(Message::Text(join_msg.to_string().into()))
.await
.expect("Failed to send JoinRoom");
// Read the RoomJoined response
if let Some(Ok(Message::Text(text))) = ws.next().await {
let response: Value = serde_json::from_str(&text)
.expect("Invalid JSON from server");
let msg_type = response["type"].as_str().unwrap_or("unknown");
let players = &response["data"]["current_players"];
let player_count = players.as_array()
.map(|a| a.len())
.unwrap_or(0);
println!("Response type: {msg_type}");
println!("Players in room: {player_count}");
}
}
Run the second client:
You should see that the room now has 2 players:
Meanwhile, the first client will receive a PlayerJoined notification
from the server indicating that Player2 has entered the room.
Step 4: Exchange Data¶
Once both players are in the room, either client can send arbitrary game
data to the other using the GameData message type. The server relays
the data to all other players in the room.
Add a send-and-receive loop to your client after joining the room:
// Send game data to all other players in the room.
// The outer "data" is the serde content tag; the inner "data"
// is the GameData variant's field name.
let game_data = serde_json::json!({
"type": "GameData",
"data": {
"data": {
"action": "move",
"x": 100,
"y": 200
}
}
});
ws.send(Message::Text(game_data.to_string().into()))
.await
.expect("Failed to send GameData");
// Listen for incoming messages
while let Some(Ok(Message::Text(text))) = ws.next().await {
let msg: Value = serde_json::from_str(&text)
.expect("Invalid JSON");
let msg_type = msg["type"].as_str().unwrap_or("unknown");
match msg_type {
"GameData" => {
let from = msg["data"]["from_player"]
.as_str()
.unwrap_or("unknown");
println!("Game data from {from}: {}", msg["data"]["data"]);
}
other => println!("Received: {other}"),
}
}
The data field inside GameData accepts any valid JSON value. Use it
to send positions, inputs, chat messages, or any game state your
application needs.
Step 5: Ready Up and Start¶
Signal Fish Server includes a lobby system that tracks when players are
ready. PlayerReady is a toggle (ready/unready for the sending player). Once
all players are ready at the same time, the lobby transitions through
Lobby to Finalized and the server sends a GameStarting event
with peer connection information.
After both clients have joined the room, send the ready signal:
// Toggle this player's ready state (first send marks ready)
let ready_msg = serde_json::json!({
"type": "PlayerReady"
});
ws.send(Message::Text(ready_msg.to_string().into()))
.await
.expect("Failed to send PlayerReady");
// Listen for lobby state changes
while let Some(Ok(Message::Text(text))) = ws.next().await {
let msg: Value = serde_json::from_str(&text)
.expect("Invalid JSON");
let msg_type = msg["type"].as_str().unwrap_or("unknown");
match msg_type {
"LobbyStateChanged" => {
let state = msg["data"]["lobby_state"]
.as_str()
.unwrap_or("unknown");
println!("Lobby state: {state}");
}
"GameStarting" => {
println!("Game is starting!");
println!("Peer connections: {}", msg["data"]["peer_connections"]);
break;
}
other => println!("Received: {other}"),
}
}
When both players send PlayerReady once (becoming ready), you will see the
lobby transition:
At this point the server has done its job: players are matched, ready, and have the information they need to establish direct peer-to-peer connections.
What's Next¶
Now that you have a working signaling flow, explore the deeper concepts and build a production-ready integration:
- Rooms and Lobbies -- understand room lifecycle, lobby states, and player management
- Authority System -- designate a host player for server-authoritative game logic
- Reconnection -- handle dropped connections gracefully with event replay
- Rust Client Guide -- build a complete, robust game client
- Configuration -- customize ports, limits, authentication, and more
- Protocol Reference -- every message type and field documented in detail