signal-fish-server 0.2.0

A lightweight, in-memory WebSocket signaling server for peer-to-peer game networking
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
<p align="center">
  <img src="docs/assets/logo-banner.svg" alt="Signal Fish Server" width="600">
</p>

<p align="center">
  <a href="https://crates.io/crates/signal-fish-server">
    <img src="https://img.shields.io/crates/v/signal-fish-server?style=for-the-badge"
         alt="crates.io">
  </a>
  <a href="https://ambiguous-interactive.github.io/signal-fish-server/">
    <img src="https://img.shields.io/badge/docs-GitHub%20Pages-blue?style=for-the-badge"
         alt="Documentation">
  </a>
  <a href="rust-toolchain.toml">
    <img src="https://img.shields.io/badge/MSRV-1.88.0-blue.svg?style=for-the-badge"
         alt="MSRV">
  </a>
  <a href="LICENSE">
    <img src="https://img.shields.io/badge/license-MIT-blue.svg?style=for-the-badge"
         alt="License: MIT">
  </a>
</p>

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](https://github.com/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

```bash
cargo run
```

The server starts on port 3536 by default.

### Docker

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

### Docker Compose

```bash
docker compose up
```

### Connect

Point your WebSocket client at:

```text
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`](config.example.json) for a complete reference with all default values.

### Example Configuration

```json
{
  "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

```text
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](.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](.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

```text
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](https://github.com/johanhelsing/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.

```bash
cargo run --features legacy-fullmesh
```

### `tls`

Adds built-in TLS and mutual TLS (mTLS) support via
[rustls](https://github.com/rustls/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.

```bash
cargo build --features tls
```

Build with all optional features:

```bash
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:

```rust
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

```bash
# Debug build
cargo build

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

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

### Test

```bash
cargo test
cargo test --all-features
```

### Lint

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

### Docker

```bash
# 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

```text
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.

```json
{
  "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](https://github.com/Ambiguous-Interactive)

See [LICENSE](LICENSE) for the full license text.