#multi-player #signaling #gamedev #matchmaking

bin+lib signal-fish-server

A lightweight, in-memory WebSocket signaling server for peer-to-peer game networking

3 unstable releases

0.2.0 Feb 24, 2026
0.1.1 Feb 23, 2026
0.1.0 Feb 19, 2026

#847 in Game dev

MIT license

1MB
19K SLoC

Rust 14K SLoC // 0.0% comments Shell 4.5K SLoC // 0.2% comments Python 48 SLoC // 0.3% comments AWK 31 SLoC // 0.5% comments

Signal Fish Server

crates.io Documentation MSRV License: MIT

A lightweight, zero-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.


Quick Start

Rust

cargo run

The server starts on port 3536 by default.

Docker

docker run -p 3536:3536 ghcr.io/ambiguous-interactive/signal-fish-server:latest

Docker Compose

docker compose up

Connect

Point your WebSocket client at:

ws://localhost:3536/v2/ws

Features

  • 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
  • Rate limiting -- in-memory rate limiting for room creation and join attempts
  • Metrics -- Prometheus-compatible metrics at /metrics/prom and 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

Endpoints

Path Method Description
/v2/ws WebSocket Signaling WebSocket endpoint
/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)

Configuration

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 reference with all default values.

Example Configuration

{
  "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
  },
  "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
  },
  "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_connections_per_ip": 10,
    "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": {}
  },
  "websocket": {
    "enable_batching": true,
    "batch_size": 10,
    "batch_interval_ms": 16,
    "auth_timeout_secs": 10
  }
}

Environment Variable Overrides

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.

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 heartbeat logs
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 IP 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 IP 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_SECURITY__CORS_ORIGINS security.cors_origins * Allowed CORS origins (comma-separated or *)
SIGNAL_FISH_SECURITY__REQUIRE_WEBSOCKET_AUTH security.require_websocket_auth false Require app authentication on WebSocket connect
SIGNAL_FISH_SECURITY__REQUIRE_METRICS_AUTH security.require_metrics_auth false 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_CONNECTIONS_PER_IP security.max_connections_per_ip 10 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
RUST_LOG -- info Standard tracing log filter

CLI Flags

signal-fish-server [OPTIONS]

Options:
      --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.

Protocol Reference

Signal Fish Server uses a JSON-based WebSocket protocol (v2). 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.

Client Messages

Canonical sample: .llm/code-samples/protocol/v2-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 to other players in the room
AuthorityRequest Request or release game authority
PlayerReady Toggle your ready/unready state in the lobby
ProvideConnectionInfo Share peer connection information for P2P establishment
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 Behavior (Implicit vs Explicit)

JoinRoom is the only room-entry message. The server resolves it using game_name and room_code:

  1. Omit room_code: create a new room with a generated room code.
  2. Provide room_code and room exists for that game_name: join that room.
  3. Provide room_code and no room exists for that game_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 Behavior

PlayerReady has no payload and works as a toggle:

  1. Send once in lobby state: you become ready.
  2. Send again in lobby state: you become unready.
  3. Each toggle broadcasts LobbyStateChanged with ready_players and all_ready.
  4. When all_ready becomes true, the server sends GameStarting.

Server Messages

Canonical sample: .llm/code-samples/protocol/v2-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
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

Typical Session Flow

Client                              Server
  |                                    |
  |--- Authenticate ------------------>|
  |<-- Authenticated ------------------|
  |<-- ProtocolInfo -------------------|
  |                                    |
  |--- JoinRoom (no room_code) ------->|
  |<-- RoomJoined ---------------------|
  |                                    |
  |         (other client joins)       |
  |<-- PlayerJoined -------------------|
  |                                    |
  |--- PlayerReady -------------------->|
  |<-- LobbyStateChanged (lobby) -------|
  |                                    |
  |--- GameData ---------------------->|
  |<-- GameData (from other player) ---|
  |                                    |
  |--- LeaveRoom --------------------->|
  |<-- PlayerLeft ---------------------|

Optional Features

Signal Fish Server supports two optional Cargo features that are disabled by default to keep the dependency tree minimal.

legacy-fullmesh

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-fullmesh

tls

Adds 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 tls

Build with all optional features:

cargo build --all-features

Library Usage

Signal 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(),
        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_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.

Building from Source

Prerequisites

  • Rust 1.88.0 or later (see rust-version in Cargo.toml)
  • No system libraries required for the default build

Build

# Debug build
cargo build

# Release build (optimized, stripped)
cargo build --release

# With all optional features
cargo build --release --all-features

Test

cargo test
cargo test --all-features

Lint

cargo fmt --check
cargo clippy --all-targets -- -D warnings
cargo clippy --all-targets --all-features -- -D warnings

Docker

# 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/health

The 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.

Project Structure

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 distributed locking
│   ├── 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

Authentication

Authentication is disabled by default. To enable it, set security.require_websocket_auth to true in your config file and 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.

MSRV

The minimum supported Rust version is 1.88.0.

License

MIT -- Ambiguous Interactive

See LICENSE for the full license text.

Dependencies

~22–41MB
~552K SLoC