# GDD: Relay Transport Layer

**System ID:** SYS-20  
**Design Order:** 2 of 33  
**Priority:** MVP — Foundation Layer  
**Dependencies:** None  
**Dependents:** SYS-33 (FishNet Replication), SYS-21 (Lobby & Matchmaking), SYS-25 (Cosmetics sync)  
**Status:** Designed  

---

## 1. Purpose

This system defines the Unity client's relationship with the relay server at `/Users/mahmoudaglan/istalk-relay-server/`. It is the **only** path game traffic takes between players — there is no direct peer-to-peer, no NAT traversal, no dedicated game server. Every byte of FishNet traffic flows through this relay.

The relay serves two completely different roles depending on match phase:

| Phase | Protocol | What the relay does |
|-------|----------|-------------------|
| **Lobby** | JSON over WebSocket text frames | Manages room state, role assignment, player list, countdown |
| **Game** | Raw binary over WebSocket binary frames | Dumb forwarder — reads first byte, routes packet, prepends sender ID |

This GDD documents both protocols exactly as they exist in the server source, plus the Unity-side `RelayClient` component that implements them.

---

## 2. Connection Lifecycle

### 2.1 States

```
Disconnected
    ↓  Connect to wss://relay-url
Connected (no room)
    ↓  send create_room / join_room
InLobby
    ↓  receive game_start
InGame (relay mode — binary frames)
    ↓  game ends / host sends return_to_lobby
InLobby  (or Disconnected on error)
```

### 2.2 URL

- **Production:** `wss://istalk-relay.caprover.al-arcade.com` (CapRover app, auto-deploys from GitLab)
- **Local dev:** `ws://localhost:3000`
- Configurable via `GlobalRules_SO` or a build-time `#if DEVELOPMENT_BUILD` flag — never hardcoded in game scripts

### 2.3 Keepalive

The server pings all clients every **30 seconds**. Clients must respond to `ping` with `pong` (the `ws` library handles this automatically at protocol level). A client that misses one ping cycle is terminated by the server. The Unity client must therefore keep the WebSocket alive throughout the match — do not close and reopen between lobby and game phases.

### 2.4 Reconnection

The current relay server has **no reconnection support** — if a client disconnects, the server calls `room.removePlayer()` and broadcasts the updated lobby state. The room is not preserved for the reconnecting player.

**MVP behaviour:** If disconnected during game, player is gone. No rejoin.  
**Alpha target:** Add a short reconnection window (30s grace) where the room holds the slot. Out of scope for MVP.

---

## 3. Lobby Phase — JSON Protocol

All lobby messages are UTF-8 JSON text frames. Binary frames during lobby state are dropped by the server.

### 3.1 Client → Server Messages

#### `create_room`
```json
{ "type": "create_room", "name": "Mahmoud" }
```
- Creates a new room with a 6-character alphanumeric code (e.g. `"XK7P2Q"`)
- Sender becomes **host** and is assigned the **Eye** role (first player = Eye)
- Server responds with `room_created`, then `lobby_state`

#### `join_room`
```json
{ "type": "join_room", "roomCode": "XK7P2Q", "name": "Player2" }
```
- Joins an existing room that is in `lobby` state
- Room must have fewer than 4 players
- Joiner is assigned **Runner** role
- Server responds with `room_joined`, then broadcasts `lobby_state` to all

#### `set_name`
```json
{ "type": "set_name", "name": "NewName" }
```
- Updates display name (max 20 chars, truncated server-side)
- Triggers `lobby_state` broadcast

#### `set_ready`
```json
{ "type": "set_ready", "ready": true }
```
- Toggles ready state for the sending player
- Game cannot start until ALL players are ready AND there are ≥ 2 players

#### `swap_role`
```json
{ "type": "swap_role" }
```
- A Runner requests to become Eye
- Server swaps: requester becomes Eye, current Eye becomes Runner
- Both players have their `isReady` reset to `false`
- Only valid during `lobby` state

#### `start_game`
```json
{ "type": "start_game" }
```
- **Host only.** Fails silently if sender is not host or room is not startable
- Server validates `canStart()`: ≥ 2 players, all ready, Eye exists
- Server sets `room.state = "starting"`, broadcasts `lobby_state`
- Server runs 3-second countdown (broadcasts `countdown` at t=2, t=1, t=0)
- At countdown zero: `room.state = "playing"`, broadcasts `game_start`

#### `leave_room`
```json
{ "type": "leave_room" }
```
- Player voluntarily leaves. Server removes them and broadcasts updated `lobby_state`
- If room becomes empty, server deletes it

#### `return_to_lobby`
```json
{ "type": "return_to_lobby" }
```
- **Host only.** Resets `room.state` to `"lobby"`, clears all ready states
- Used after a match ends to bring everyone back to lobby without disconnecting

---

### 3.2 Server → Client Messages

#### `room_created`
```json
{ "type": "room_created", "roomCode": "XK7P2Q", "playerId": 1 }
```
- Sent only to the creating player
- `playerId` is the server-assigned integer ID for this session (starts at 1, increments)

#### `room_joined`
```json
{ "type": "room_joined", "roomCode": "XK7P2Q", "playerId": 2 }
```
- Sent only to the joining player

#### `lobby_state`
```json
{
  "type": "lobby_state",
  "state": "lobby",
  "roomCode": "XK7P2Q",
  "players": [
    { "id": 1, "name": "Mahmoud", "role": "Eye",    "isReady": true,  "isHost": true  },
    { "id": 2, "name": "Player2", "role": "Runner",  "isReady": false, "isHost": false }
  ]
}
```
- Broadcast to **all** players in the room on any state change
- `state` field: `"lobby"` | `"starting"` | `"playing"`
- Roles: `"Eye"` | `"Runner"` (string literals — match exactly)

#### `countdown`
```json
{ "type": "countdown", "seconds": 2 }
```
- Sent at `seconds = 2`, then `1`, then `0`
- UI shows countdown. At `seconds = 0`, `game_start` arrives immediately after

#### `game_start`
```json
{
  "type": "game_start",
  "players": [
    { "id": 1, "name": "Mahmoud", "role": "Eye",   "isReady": true, "isHost": true  },
    { "id": 2, "name": "Player2", "role": "Runner", "isReady": true, "isHost": false }
  ],
  "hostId": 1
}
```
- **Critical message.** Triggers the transition to game phase on the Unity client.
- `hostId` identifies which `playerId` runs FishNet as host (server+client)
- The host starts FishNet server. All other players start FishNet client and connect to the host via the relay.

#### `error`
```json
{ "type": "error", "error": "Room not found" }
```
- Non-fatal. Log and surface to player in UI.

---

## 4. Game Phase — Binary Packet Protocol

Once `game_start` is received, the same WebSocket connection switches to binary frame mode. **No more JSON is sent during gameplay** (except `return_to_lobby` after match end, which is JSON).

The relay does NOT understand FishNet or Synapse. It reads exactly **1 byte** (the routing header) and forwards the rest verbatim, prepending the sender's 2-byte player ID.

### 4.1 Outbound Packet Format (Client → Relay)

```
Byte 0:     target byte (routing instruction)
Bytes 1..N: Synapse payload (FishNet data — relay does not parse this)
```

**Target byte values:**

| Value | Name | Routing |
|-------|------|---------|
| `0x00` | Broadcast | Send to all players in room **except** sender |
| `0x01` | ToHost | Send only to the host player (`hostId`) |
| `0xFF` | FromHost | Host broadcasting to all clients (semantically identical to `0x00` but logged differently) |
| `0x02`–`0xFE` | ToPlayer | Send to the specific player whose `playerId == target` |

### 4.2 Inbound Packet Format (Relay → Client)

```
Bytes 0-1:  sender playerId (uint16, little-endian)
Bytes 2..N: Synapse payload (same as what sender sent in bytes 1..N)
```

The relay strips the target byte, prepends the 2-byte sender ID, and forwards. The receiving client reads bytes 0–1 to learn who sent it, then passes bytes 2..N to Synapse/FishNet.

### 4.3 Minimum Packet Size

The relay drops binary packets shorter than 2 bytes. Synapse must always send at least 1 routing byte + 1 payload byte.

### 4.4 Guard Condition

Binary packets are **only forwarded** when `room.state === 'playing'`. Packets sent before `game_start` or after `return_to_lobby` are silently dropped.

---

## 5. Host Election

The host is always the player who created the room (first joiner, `isHost: true`). Host election is **deterministic and server-assigned** — the client cannot request to be host.

**Implications for FishNet:**
- On `game_start`, the client whose `playerId === hostId` calls `NetworkManager.ServerManager.StartConnection()` (start as host)
- All other clients call `NetworkManager.ClientManager.StartConnection()` (start as client, connecting through relay)
- If the host disconnects mid-match, the relay removes them from the room and broadcasts `lobby_state`. **There is no host migration in MVP.** The match ends for remaining players.

**Host = Eye role** in the current server implementation (`addPlayer` assigns Eye to first joiner). This is intentional — the Eye's server authority is consistent with them being the FishNet host.

---

## 6. Unity Client Implementation — `RelayClient`

A single `MonoBehaviour` manages the entire relay connection. It is not a `NetworkBehaviour` — it exists outside FishNet's authority model.

```csharp
public class RelayClient : MonoBehaviour
{
    // ── Configuration ──────────────────────────────────────────────────
    [SerializeField] private string _relayUrl = "wss://istalk-relay.caprover.al-arcade.com";

    // ── Runtime State ───────────────────────────────────────────────────
    public int     LocalPlayerId { get; private set; }
    public int     HostPlayerId  { get; private set; }
    public bool    IsHost        => LocalPlayerId == HostPlayerId;
    public string  RoomCode      { get; private set; }
    public RelayConnectionState State { get; private set; }

    // ── Events (subscribed by LobbyUI, MatchManager, etc.) ─────────────
    public event Action<LobbyStateMsg>  OnLobbyState;
    public event Action<GameStartMsg>   OnGameStart;
    public event Action<int>            OnCountdown;      // seconds remaining
    public event Action<byte[]>         OnGamePacket;     // raw bytes 0..N (sender prepended)
    public event Action<string>         OnError;
    public event Action                 OnDisconnected;

    // ── Public API ──────────────────────────────────────────────────────
    public Task ConnectAsync();
    public void CreateRoom(string playerName);
    public void JoinRoom(string roomCode, string playerName);
    public void SetReady(bool ready);
    public void SwapRole();
    public void StartGame();             // Host only
    public void LeaveRoom();
    public void ReturnToLobby();         // Host only, post-match

    // Called by Synapse transport during gameplay
    public void SendGamePacket(byte targetByte, byte[] payload);
}

public enum RelayConnectionState
{
    Disconnected, Connecting, Connected, InLobby, InGame
}
```

### 6.1 Sending a Game Packet

```csharp
public void SendGamePacket(byte targetByte, byte[] payload)
{
    // Build: [target (1 byte)] + [payload]
    var packet = new byte[1 + payload.Length];
    packet[0] = targetByte;
    Buffer.BlockCopy(payload, 0, packet, 1, payload.Length);
    _ws.Send(packet);  // binary frame
}
```

### 6.2 Receiving a Game Packet

```csharp
private void OnBinaryMessage(byte[] data)
{
    // Relay prepends 2-byte sender ID
    // bytes 0-1 = senderId (uint16 LE)
    // bytes 2..N = Synapse payload
    OnGamePacket?.Invoke(data);  // pass full buffer; Synapse reads offset 2
}
```

### 6.3 Transition on `game_start`

```csharp
private void HandleGameStart(GameStartMsg msg)
{
    HostPlayerId = msg.hostId;
    State = RelayConnectionState.InGame;
    OnGameStart?.Invoke(msg);
    // Caller (MatchManager) reads IsHost and starts FishNet accordingly
}
```

---

## 7. Synapse Transport Integration

Synapse (the FishNet transport at `Assets/FishNet/Runtime/Transporting/Transports/Synapse/`) needs to route its UDP-style packets through the relay WebSocket instead of opening actual UDP sockets.

**Integration point:** Synapse's `ClientSocket` and `ServerSocket` send/receive methods are overridden or wrapped to call `RelayClient.SendGamePacket()` and receive from `RelayClient.OnGamePacket`.

**Sender ID offset:** When Synapse receives a packet from `OnGamePacket`, bytes 0–1 are the relay-prepended sender ID. Synapse must skip these 2 bytes when parsing the FishNet payload. This is the "channel bytes" concern mentioned in the FishNet global rules — **preserve the sender ID prefix, do not strip it before FishNet reads it** unless Synapse handles the offset internally.

**Routing for FishNet traffic:**

| FishNet scenario | Target byte to use |
|------------------|--------------------|
| Client → Server (host) | `0x01` (ToHost) |
| Server (host) → All clients | `0xFF` (FromHost) |
| Server → Specific client | player's `playerId` as target byte |
| Client broadcast (rare) | `0x00` (Broadcast) |

---

## 8. Server Health & Monitoring

The relay exposes two HTTP endpoints (no auth required):

```
GET /health  → { status: "ok", rooms: N, connections: N, uptime: N }
GET /stats   → { totalRooms: N, rooms: [ { code, state, players, createdAt } ] }
```

Use `/health` for CapRover health checks. Use `/stats` during development to inspect active rooms.

---

## 9. Known Limitations (MVP)

| Limitation | Impact | Future Fix |
|------------|--------|-----------|
| No reconnection | Host or client disconnect = match over | Alpha: hold slot for 30s, allow rejoin |
| Max 4 players hard-coded in server (`getPlayerCount() >= 4`) | Can't test 1v4 without server change | Bump limit in `rooms.js` before Beta |
| No authentication | Anyone with a room code can join | Post-launch: add token-based auth |
| Single relay instance | All matches on one server | Post-launch: horizontal scaling with room affinity |
| Host migration absent | Eye disconnect = everyone loses | Post-launch: promote first Runner to host |
| Room code collision possible (though unlikely) | Two rooms same code | Already handled: server loops until unique code found |

---

## 10. MVP Scope

- [x] `RelayClient` MonoBehaviour with full JSON signaling (all 8 message types)
- [x] Binary frame send/receive wired to Synapse transport
- [x] Host detection via `hostId` from `game_start`
- [x] Event-based architecture (no polling, no coroutine loops)
- [x] Keepalive pong response (WebSocket library handles automatically)
- [x] Error handling: surface `error` messages to lobby UI
- [x] Disconnect handling: return to main menu, surface message
- [ ] Reconnection window (Alpha)
- [ ] Host migration (post-launch)
- [ ] Auth tokens (post-launch)

---

*System: SYS-20 — Relay Transport Layer*  
*GDD Version: 1.0*  
*Created: 2026-06-23*  
*Relay server source: `/Users/mahmoudaglan/istalk-relay-server/src/`*  
*Status: Complete — ready for implementation*
