A lightweight, zero-external-dependency WebSocket signaling server for peer-to-peer game networking. Run locally with Rust or Docker -- no database, no cloud services required.
Built by Ambiguous Interactive.
🤖 AI Disclosure
This project was developed with substantial AI assistance. The protocol design and core technology concepts were created entirely by humans, but the vast majority of the code, documentation, and tests were written with the help of Claude Opus 4.6 and Codex 5.3. Human oversight covered code review and architectural decisions, but day-to-day implementation was primarily AI-driven. This transparency is provided so users can make informed decisions about using this crate.
cargo runThe server starts on port 3536 by default.
docker run -p 3536:3536 ghcr.io/ambiguous-interactive/signal-fish-server:latestThe published image is a multi-architecture manifest, so the same tag pulls the
right build automatically on linux/amd64, linux/arm64 (AWS Graviton, Ampere,
Raspberry Pi 64-bit, Apple Silicon under Docker), and linux/arm/v7 (32-bit
ARM / Raspberry Pi).
docker compose upEach GitHub Release ships standalone, self-contained executables — no Rust toolchain or Docker required — for:
| OS | Architectures |
|---|---|
| Linux | x86_64, aarch64 (ARM64) |
| macOS | x86_64 (Intel), aarch64 (Apple Silicon) |
| Windows | x86_64, aarch64 (ARM64) |
Every archive is accompanied by a .sha256 checksum for verification. Download
the archive for your platform, extract it, and run the signal-fish-server
binary.
Point your WebSocket client at:
ws://localhost:3536/v2/ws
- Room management -- create and join rooms with auto-generated 6-character room codes
- Lobby state machine -- waiting, countdown, and playing states with automatic transitions
- Player ready-state -- per-player ready toggles that drive lobby state progression
- Authority management -- request and grant game authority to specific players
- Spectator mode -- join rooms as a spectator without participating in gameplay
- Reconnection -- token-based reconnection with event replay within a configurable window
- Message batching -- configurable batching for high-throughput game data delivery
- Delivery classes -- v3 reliable, keyed-latest, and volatile JSON relay with exact gap accountability
- Rate limiting -- in-memory rate limiting for room creation and join attempts
- Metrics -- Prometheus-compatible metrics at
/metrics/promand JSON metrics at/metrics - Flexible configuration -- JSON config file with environment variable overrides
- Optional authentication -- config-file-backed app authentication with per-app rate limits
- Zero external dependencies -- everything runs in-memory; no database, no message broker, no cloud services
| Path | Method | Description |
|---|---|---|
/v2/ws |
WebSocket | Signaling WebSocket endpoint |
/v3/ws |
WebSocket | Protocol v3 WebSocket alias |
/v2/health |
GET | Health check (returns 200 OK) |
/metrics |
GET | JSON server metrics |
/v1/metrics |
GET | JSON server metrics (alias) |
/metrics/prom |
GET | Prometheus text format metrics |
/v1/metrics/prom |
GET | Prometheus text format metrics (alias) |
Signal Fish Server is configured through a JSON config file and environment variable overrides.
On startup the server looks for config.json in the working directory. See
config.example.json for a complete local-development reference.
{
"port": 3536,
"server": {
"default_max_players": 8,
"ping_timeout": 30,
"room_cleanup_interval": 60,
"max_rooms_per_game": 1000,
"empty_room_timeout": 300,
"inactive_room_timeout": 3600,
"reconnection_window": 300,
"event_buffer_size": 100,
"enable_reconnection": true,
"heartbeat_throttle_secs": 30,
"region_id": "default"
},
"rate_limit": {
"max_room_creations": 5,
"time_window": 60,
"max_join_attempts": 20,
"max_signals": 600,
"max_signal_errors": 60
},
"protocol": {
"max_game_name_length": 64,
"room_code_length": 6,
"max_player_name_length": 32,
"max_players_limit": 100,
"enable_message_pack_game_data": true,
"min_protocol_version": 2,
"max_protocol_version": 3
},
"logging": {
"dir": "logs",
"filename": "server.log",
"rotation": "daily",
"enable_file_logging": true,
"format": "json"
},
"security": {
"cors_origins": "*",
"require_websocket_auth": false,
"require_metrics_auth": false,
"max_message_size": 65536,
"max_signal_bytes": 16384,
"max_connections_per_ip": 24,
"transport": {
"tls": { "enabled": false },
"token_binding": { "enabled": false }
},
"authorized_apps": [
{
"app_id": "my-game",
"app_secret": "CHANGE_ME_BEFORE_PRODUCTION",
"app_name": "My Awesome Game",
"max_rooms": 100,
"max_players_per_room": 16,
"rate_limit_per_minute": 60
}
]
},
"coordination": {
"dedup_cache": {
"capacity": 100000,
"ttl_secs": 60,
"cleanup_interval_secs": 30
},
"membership_snapshot_interval_secs": 30
},
"metrics": {
"dashboard_cache_refresh_interval_secs": 5,
"dashboard_cache_ttl_secs": 30,
"dashboard_cache_history_window_secs": 300
},
"relay_types": {
"default_relay_type": "matchbox",
"game_relay_mappings": {}
},
"session": {
"default_topology": "relay",
"game_topology_mappings": {},
"enable_webrtc": true,
"enable_direct": true,
"enable_ice_pregather": true,
"ice_servers": []
},
"turn": {
"enabled": false,
"static_auth_secret": "",
"urls": [],
"stun_urls": ["stun:stun.l.google.com:19302"],
"credential_ttl_secs": 3600
},
"websocket": {
"enable_batching": true,
"batch_size": 10,
"batch_interval_ms": 16,
"auth_timeout_secs": 10,
"idle_timeout_secs": 300,
"server_ping_interval_secs": 10,
"pong_timeout_secs": 5,
"send_queue_capacity": 1024,
"control_queue_capacity": 128,
"slow_consumer_timeout_ms": 5000,
"max_sojourn_ms": 15000,
"delivery_stats_interval_secs": 0
}
}Any configuration field can be overridden with an environment variable using the
SIGNAL_FISH__ prefix. Nested fields use double underscores (__) as separators.
Values are parsed as the type expected by the corresponding config field. Common
overrides are listed below; see Configuration for the
complete reference.
| Environment Variable | Config Path | Default | Description |
|---|---|---|---|
SIGNAL_FISH__PORT |
port |
3536 |
Server listen port |
SIGNAL_FISH__SERVER__DEFAULT_MAX_PLAYERS |
server.default_max_players |
8 |
Default max players per room |
SIGNAL_FISH__SERVER__PING_TIMEOUT |
server.ping_timeout |
30 |
Seconds before a silent client is dropped |
SIGNAL_FISH__SERVER__ROOM_CLEANUP_INTERVAL |
server.room_cleanup_interval |
60 |
Seconds between room cleanup sweeps |
SIGNAL_FISH__SERVER__MAX_ROOMS_PER_GAME |
server.max_rooms_per_game |
1000 |
Max rooms allowed per game name |
SIGNAL_FISH__SERVER__EMPTY_ROOM_TIMEOUT |
server.empty_room_timeout |
300 |
Seconds before an empty room is removed |
SIGNAL_FISH__SERVER__INACTIVE_ROOM_TIMEOUT |
server.inactive_room_timeout |
3600 |
Seconds before an inactive room is removed |
SIGNAL_FISH__SERVER__RECONNECTION_WINDOW |
server.reconnection_window |
300 |
Seconds a reconnection token stays valid |
SIGNAL_FISH__SERVER__EVENT_BUFFER_SIZE |
server.event_buffer_size |
100 |
Max events buffered for reconnection replay |
SIGNAL_FISH__SERVER__ENABLE_RECONNECTION |
server.enable_reconnection |
true |
Enable reconnection support |
SIGNAL_FISH__SERVER__HEARTBEAT_THROTTLE_SECS |
server.heartbeat_throttle_secs |
30 |
Min seconds between last_seen heartbeat writes |
SIGNAL_FISH__SERVER__REGION_ID |
server.region_id |
default |
Region identifier for metrics |
SIGNAL_FISH__RATE_LIMIT__MAX_ROOM_CREATIONS |
rate_limit.max_room_creations |
5 |
Max room creations per player per window |
SIGNAL_FISH__RATE_LIMIT__TIME_WINDOW |
rate_limit.time_window |
60 |
Rate limit window in seconds |
SIGNAL_FISH__RATE_LIMIT__MAX_JOIN_ATTEMPTS |
rate_limit.max_join_attempts |
20 |
Max join attempts per player per window |
SIGNAL_FISH__RATE_LIMIT__MAX_SIGNALS |
rate_limit.max_signals |
600 |
Max validated WebRTC Signal dispatch attempts per player per window |
SIGNAL_FISH__RATE_LIMIT__MAX_SIGNAL_ERRORS |
rate_limit.max_signal_errors |
60 |
Max rejected WebRTC signals per player per window |
SIGNAL_FISH__PROTOCOL__MAX_GAME_NAME_LENGTH |
protocol.max_game_name_length |
64 |
Max characters in a game name |
SIGNAL_FISH__PROTOCOL__ROOM_CODE_LENGTH |
protocol.room_code_length |
6 |
Length of generated room codes |
SIGNAL_FISH__PROTOCOL__MAX_PLAYER_NAME_LENGTH |
protocol.max_player_name_length |
32 |
Max characters in a player name |
SIGNAL_FISH__PROTOCOL__MAX_PLAYERS_LIMIT |
protocol.max_players_limit |
100 |
Hard ceiling on players per room |
SIGNAL_FISH__PROTOCOL__ENABLE_MESSAGE_PACK_GAME_DATA |
protocol.enable_message_pack_game_data |
true |
Enable MessagePack game-data frames |
SIGNAL_FISH__PROTOCOL__MIN_PROTOCOL_VERSION |
protocol.min_protocol_version |
2 |
Lowest accepted protocol version |
SIGNAL_FISH__PROTOCOL__MAX_PROTOCOL_VERSION |
protocol.max_protocol_version |
3 |
Highest negotiated protocol version |
SIGNAL_FISH__LOGGING__LEVEL |
logging.level |
null |
Log level override (trace, debug, info, warn, error) |
SIGNAL_FISH__LOGGING__ENABLE_FILE_LOGGING |
logging.enable_file_logging |
true |
Enable rolling file logs |
SIGNAL_FISH__LOGGING__FORMAT |
logging.format |
json |
Log output format (json or text) |
SIGNAL_FISH__SECURITY__CORS_ORIGINS |
security.cors_origins |
http://localhost:3000,http://localhost:5173 |
Allowed CORS origins (comma-separated or *) |
SIGNAL_FISH__SECURITY__REQUIRE_WEBSOCKET_AUTH |
security.require_websocket_auth |
true |
Require app authentication on WebSocket connect |
SIGNAL_FISH__SECURITY__REQUIRE_METRICS_AUTH |
security.require_metrics_auth |
true |
Require auth token for metrics endpoints |
SIGNAL_FISH__SECURITY__MAX_MESSAGE_SIZE |
security.max_message_size |
65536 |
Max WebSocket message size in bytes |
SIGNAL_FISH__SECURITY__MAX_SIGNAL_BYTES |
security.max_signal_bytes |
16384 |
Max serialized size of a v3 Signal payload |
SIGNAL_FISH__SECURITY__MAX_CONNECTIONS_PER_IP |
security.max_connections_per_ip |
24 |
Max concurrent connections from one IP |
SIGNAL_FISH__WEBSOCKET__ENABLE_BATCHING |
websocket.enable_batching |
true |
Enable outbound message batching |
SIGNAL_FISH__WEBSOCKET__BATCH_SIZE |
websocket.batch_size |
10 |
Max messages per batch |
SIGNAL_FISH__WEBSOCKET__BATCH_INTERVAL_MS |
websocket.batch_interval_ms |
16 |
Batch flush interval in milliseconds |
SIGNAL_FISH__WEBSOCKET__AUTH_TIMEOUT_SECS |
websocket.auth_timeout_secs |
10 |
Seconds to wait for auth after connect |
SIGNAL_FISH__WEBSOCKET__IDLE_TIMEOUT_SECS |
websocket.idle_timeout_secs |
300 |
Post-auth idle timeout in seconds (0 disables) |
SIGNAL_FISH__WEBSOCKET__SERVER_PING_INTERVAL_SECS |
websocket.server_ping_interval_secs |
10 |
Server RFC 6455 Ping cadence (0 disables) |
SIGNAL_FISH__WEBSOCKET__PONG_TIMEOUT_SECS |
websocket.pong_timeout_secs |
5 |
Matching Pong deadline before close 4003 |
SIGNAL_FISH__WEBSOCKET__SEND_QUEUE_CAPACITY |
websocket.send_queue_capacity |
1024 |
Per-connection data queue capacity |
SIGNAL_FISH__WEBSOCKET__CONTROL_QUEUE_CAPACITY |
websocket.control_queue_capacity |
128 |
Per-connection v3 control queue capacity |
SIGNAL_FISH__WEBSOCKET__SLOW_CONSUMER_TIMEOUT_MS |
websocket.slow_consumer_timeout_ms |
5000 |
Reliable capacity wait before close 4002 |
SIGNAL_FISH__WEBSOCKET__MAX_SOJOURN_MS |
websocket.max_sojourn_ms |
15000 |
Oldest outbound-item/write deadline |
SIGNAL_FISH__WEBSOCKET__DELIVERY_STATS_INTERVAL_SECS |
websocket.delivery_stats_interval_secs |
0 |
Periodic v3 counter snapshots (0 disables) |
RUST_LOG |
-- | info |
Standard tracing log filter |
signal-fish-server [OPTIONS]
Options:
-c, --validate-config Validate config and exit
--print-config Print resolved config as JSON and exit
-h, --help Print help
-V, --version Print version
Note: The server automatically loads config.json from the working
directory if it exists. Use environment variables to override specific
configuration values.
Signal Fish Server uses a JSON-based WebSocket protocol with a frozen v2 relay
floor and additive v3 capabilities. Messages are JSON objects with a type
field and an optional data field. MessagePack encoding is also supported for
game data when enable_message_pack_game_data is enabled.
Building a client? See the Platform Integration Guide for which WebRTC stack to use per platform (browser, native, mobile, Steam, Godot, Unity, Unreal), and the Rust Client Guide for a complete relay-floor client walkthrough.
Canonical sample: .llm/code-samples/protocol/v2-client-messages.jsonl (v3 additions: .llm/code-samples/protocol/v3-client-messages.jsonl)
| Message | Description |
|---|---|
Authenticate |
Authenticate with app ID (required when auth is enabled) |
JoinRoom |
Join or create a room (implicit create with no room_code, explicit join/create with room_code) |
GameData |
Send arbitrary game data; negotiated v3 JSON may select reliable, keyed-latest, or volatile delivery |
AuthorityRequest |
Request or release game authority |
PlayerReady |
Toggle your ready/unready state in the lobby |
ProvideConnectionInfo |
Share legacy self-declared metadata for v2/back-compat; not v3 capability proof |
Reconnect |
Reconnect after disconnect using player_id, room_id, and auth_token |
JoinAsSpectator |
Join a room as a spectator (read-only observer) |
LeaveSpectator |
Leave spectator mode |
LeaveRoom |
Leave the current room |
Ping |
Heartbeat ping (server responds with Pong) |
JoinRoom is the only room-entry message. The server resolves it using
game_name and room_code:
- Omit
room_code: create a new room with a generated room code. - Provide
room_codeand room exists for thatgame_name: join that room. - Provide
room_codeand no room exists for thatgame_name: create a new room with that room code.
In all successful cases, the caller receives RoomJoined. When joining an
existing room, current members also receive PlayerJoined.
PlayerReady has no payload and works as a toggle:
- Send once in
lobbystate: you become ready. - Send again in
lobbystate: you become unready. - Each toggle broadcasts
LobbyStateChangedwithready_playersandall_ready. - When
all_readybecomestrue, the server sendsGameStarting.
Canonical sample: .llm/code-samples/protocol/v2-server-messages.jsonl (v3 additions: .llm/code-samples/protocol/v3-server-messages.jsonl)
| Message | Description |
|---|---|
Authenticated |
Auth succeeded; includes app metadata and rate limits |
ProtocolInfo |
SDK/protocol compatibility details and capabilities |
RoomJoined |
Successfully joined a room |
PlayerJoined |
Another player joined the room |
PlayerLeft |
A player left the room |
GameData |
Game data relayed from another player |
DeliveryReport |
V3 cumulative per-class outcomes and exact prior gap ranges |
LobbyStateChanged |
Lobby state transitioned (waiting, lobby, finalized) |
AuthorityResponse |
Authority request result |
Error |
An error occurred; includes message and optional code |
Pong |
Response to a client Ping |
Delivery class policy is per recipient. A classified v3 send can use
latest/volatile policy for a v3 peer while the same message remains reliable
FIFO, with v3 fields stripped, for a v2 peer. Raw binary game data is always
reliable. On a continuing v3 stream, only the union of causally prior exact
DeliveryReport ranges authorizes a sequence hole; aggregate counters do not.
Operators can inspect raw JSON
metricsSnapshot.connections.delivery_by_class from
/metrics?includeSnapshot=true, or Prometheus
signal_fish_websocket_delivery_class_outcomes_total{class,outcome}. At
quiescence, each class's attempted value equals the sum of its terminal
outcomes. See Delivery semantics.
Client Server
| |
|--- Authenticate ------------------>|
|<-- Authenticated ------------------|
|<-- ProtocolInfo -------------------|
| |
|--- JoinRoom (no room_code) ------->|
|<-- RoomJoined ---------------------|
| |
| (other client joins) |
|<-- PlayerJoined -------------------|
| |
|--- PlayerReady ------------------->|
|<-- LobbyStateChanged (lobby) ------|
| |
|--- GameData ---------------------->|
|<-- GameData (from other player) ---|
| |
|--- LeaveRoom --------------------->|
|<-- RoomLeft -----------------------|
| |
| (other clients receive |
| PlayerLeft) |
Signal Fish Server supports two optional Cargo features that are disabled by default to keep the dependency tree minimal.
Enables the upstream matchbox
full-mesh signaling mode. When activated, set MATCHBOX_ENHANCED_MODE=false to
run in legacy mode. The legacy signaling server listens on port+1.
cargo run --features legacy-fullmeshAdds built-in TLS and mutual TLS (mTLS) support via
rustls. When enabled, configure TLS
through the security.transport.tls section of the config file. Most
deployments should use a reverse proxy (nginx, Caddy, cloud load balancer)
instead of built-in TLS.
cargo build --features tlsBuild with all optional features:
cargo build --all-featuresSignal Fish Server is published as both a binary (signal-fish-server) and a
library crate (signal_fish_server). You can embed the signaling server into
your own Rust application:
use signal_fish_server::{
config,
database::DatabaseConfig,
server::{EnhancedGameServer, ServerConfig},
websocket,
};
use std::{net::SocketAddr, sync::Arc};
#[tokio::main]
async fn main() -> anyhow::Result<()> {
// Load configuration from config.json + environment variables
let cfg = Arc::new(config::load());
// Build the server configuration (see main.rs for the full field mapping)
let server_config = ServerConfig {
default_max_players: cfg.server.default_max_players,
..Default::default()
};
// Create the game server with in-memory storage
let game_server = EnhancedGameServer::new(
server_config,
cfg.protocol.clone(),
cfg.relay_types.clone(),
cfg.session.clone(),
cfg.turn.clone(),
DatabaseConfig::InMemory,
cfg.metrics.clone(),
cfg.auth.clone(),
cfg.coordination.clone(),
cfg.security.transport.clone(),
cfg.security.authorized_apps.clone(),
)
.await?;
// Start background cleanup task
let cleanup = game_server.clone();
tokio::spawn(async move { cleanup.cleanup_task().await });
// Build the Axum router
let router = websocket::create_standalone_router(&cfg.security.cors_origins)
.with_state(game_server);
// Start listening
let addr = SocketAddr::from(([0, 0, 0, 0], cfg.port));
let listener = tokio::net::TcpListener::bind(addr).await?;
axum::serve(
listener,
router.into_make_service_with_connect_info::<SocketAddr>(),
)
.await?;
Ok(())
}The GameDatabase trait is public, so you can implement your own storage
backend if you need persistence beyond the built-in InMemoryDatabase.
- Rust 1.89.0 or later (see
rust-versioninCargo.toml) - No system libraries required for the default build
# Debug build
cargo build
# Release build (optimized, stripped)
cargo build --release
# With all optional features
cargo build --release --all-featurescargo test
cargo test --all-featurescargo fmt --check
cargo clippy --all-targets -- -D warnings
cargo clippy --all-targets --all-features -- -D warnings# Build the image
docker build -t signal-fish-server .
# Run it
docker run -p 3536:3536 signal-fish-server
# With a custom config
docker run -p 3536:3536 -v ./config.json:/app/config.json:ro signal-fish-server
# Verify it is running
curl http://localhost:3536/v2/healthThe Docker image uses a multi-stage build with cargo-chef for dependency
caching and mold for fast linking. The final runtime image is based on
debian:bookworm-slim and runs as a non-root user.
signal-fish-server/
├── src/
│ ├── main.rs # Binary entry point
│ ├── lib.rs # Library crate root
│ ├── server.rs # EnhancedGameServer core
│ ├── broadcast.rs # Zero-copy broadcast primitives
│ ├── distributed.rs # In-memory coordination extension seams
│ ├── logging.rs # tracing-subscriber initialization
│ ├── metrics.rs # Atomic counters + HDR histograms
│ ├── rate_limit.rs # In-memory rate limiter
│ ├── reconnection.rs # Token-based reconnection manager
│ ├── retry.rs # Exponential backoff utility
│ ├── rkyv_utils.rs # Zero-copy serialization helpers
│ ├── auth/ # In-memory app authentication
│ ├── config/ # JSON + env var configuration
│ ├── coordination/ # Room coordination and dedup cache
│ ├── database/ # GameDatabase trait + InMemoryDatabase
│ ├── protocol/ # Message types, room state, error codes
│ ├── security/ # TLS (optional) and crypto utilities
│ ├── server/ # Room service, messaging, authority, etc.
│ └── websocket/ # WebSocket handler, routes, batching
├── tests/ # Integration, e2e, concurrency, load tests
├── benches/ # Criterion benchmarks
├── third_party/rmp/ # Patched rmp crate (removes paste dep)
├── Cargo.toml
├── Dockerfile
├── docker-compose.yml
├── config.example.json
└── clippy.toml
The compiled config default requires WebSocket authentication. The development
example config opts out with security.require_websocket_auth: false; remove
that override or set it to true, then add entries to the
security.authorized_apps array.
When authentication is enabled, clients must send an Authenticate message
immediately after connecting. The server validates the app_id against the
configured authorized apps and enforces per-app rate limiting based on the
rate_limit_per_minute field.
{
"security": {
"require_websocket_auth": true,
"authorized_apps": [
{
"app_id": "my-game",
"app_secret": "a-strong-secret-here",
"app_name": "My Game",
"max_rooms": 100,
"max_players_per_room": 16,
"rate_limit_per_minute": 60
}
]
}
}Important: Change the default app_secret value before deploying to
production. The example value CHANGE_ME_BEFORE_PRODUCTION in
config.example.json is intentionally insecure configuration.
The minimum supported Rust version is 1.89.0.
MIT -- Ambiguous Interactive
See LICENSE for the full license text.