# GDD: Lobby & Matchmaking

**System ID:** SYS-21  
**Design Order:** 9 of 33  
**Priority:** MVP — Core Layer  
**Dependencies:** SYS-20 (Relay Transport Layer — the WebSocket and all JSON messages), SYS-22 (Match State Machine — game_start triggers phase transition to Grace)  
**Dependents:** SYS-29 (Menu & Lobby UI — all visual lobby elements), SYS-23 (Scoring & Results — ReturnToLobby after match)  
**Status:** Designed  

---

## 1. Purpose

This system owns the complete journey from "player opens the game" to "FishNet match is running." It bridges two distinct concerns:

1. **Relay lobby signaling** — JSON messages for room creation, joining, role selection, ready state, countdown
2. **FishNet game start** — translating `game_start` data into a running FishNet server+client topology

This GDD documents both, plus an important architecture correction from SYS-33's earlier simplified assumption about host vs. Eye identity.

---

## 2. Architecture Correction: Host ≠ Eye is Possible

SYS-33 stated: "The Eye player is always the host." This is the **default** behavior — the first player to create a room is assigned the Eye role AND is the relay host. However, `rooms.js` supports `swap_role`, and calling it can produce a state where the host (room creator, relay `isHost: true`) plays Runner, while a different player has the Eye role.

### 2.1 The Two IDs

| Concept | Who it is | Determined by |
|---------|-----------|---------------|
| **Relay host** (`hostId`) | The player with `isHost: true` in the room — always the room creator | `create_room` message, never changes |
| **Eye player** | The player with `role: "Eye"` in lobby state | Initially room creator, can change via `swap_role` |

After a `swap_role`, these two can be different players.

### 2.2 Who Runs FishNet Server?

**The relay host (`hostId`) always runs FishNet as server+client.** Not the Eye player. The FishNet server is the relay host regardless of game role.

This means:
- **Default case (no swap):** host = Eye = FishNet server. Simple, clean, every SYS-33 code example is correct.
- **After swap:** host = Runner = FishNet server. Eye is a FishNet client. FishNet server authority still works — it's just held by a Runner instead of the Eye.

FishNet's authority model is role-agnostic. The server validates all state regardless of what game role it plays. The "Eye has server authority over kills and traps" in SYS-33 was written assuming no swap had occurred.

### 2.3 MVP Decision: Disable Role Swap UI

For MVP, **role swapping is not exposed in the lobby UI**. The relay supports it at the protocol level, but the MVP lobby shows no "swap role" button. First joiner = Eye = host = FishNet server. This keeps the SYS-33 codebase clean and avoids the "host is a Runner" edge case until Alpha when the lobby UI is built out.

**Alpha target:** Expose swap_role in a full lobby UI. At that point, update FishNet's `GameManager.GetRole(conn)` to read from `game_start.players[].role` rather than assuming host == Eye.

---

## 3. Lobby Flow (MVP)

```
Player A opens game
    → Clicks "Create Room"
    → RelayClient.ConnectAsync()
    → RelayClient.CreateRoom("Player A")
    → Receives: room_created { roomCode: "XK7P2Q", playerId: 1 }
    → Receives: lobby_state { players: [{ id:1, role:"Eye", isReady:false, isHost:true }] }
    → UI shows: Room Code "XK7P2Q", Player A as Eye

Player B opens game
    → Enters room code "XK7P2Q", clicks "Join"
    → RelayClient.ConnectAsync()
    → RelayClient.JoinRoom("XK7P2Q", "Player B")
    → Receives: room_joined { roomCode: "XK7P2Q", playerId: 2 }
    → Receives: lobby_state { players: [{ id:1, role:"Eye"... }, { id:2, role:"Runner"... }] }
    → Both players see lobby with roles assigned

Both players click "Ready"
    → Each sends: set_ready { ready: true }
    → Both receive: lobby_state (all isReady: true)
    → "Start Game" button becomes available to host (Player A)

Player A clicks "Start Game"
    → Sends: start_game
    → Relay validates canStart() (≥2 players, all ready, has Eye)
    → Relay sets room.state = "starting"
    → Broadcast: lobby_state { state: "starting" }
    → Countdown broadcasts: countdown { seconds: 2 }, countdown { seconds: 1 }, countdown { seconds: 0 }
    → Broadcast: game_start { players: [...], hostId: 1 }

On receiving game_start:
    → Both clients call LobbyManager.HandleGameStart(msg)
    → LobbyManager loads the game scene
    → After scene load: FishNet started (host or client based on hostId)
```

---

## 4. LobbyManager MonoBehaviour

`LobbyManager` is a singleton `MonoBehaviour` (not a `NetworkBehaviour` — it exists before FishNet starts). It subscribes to `RelayClient` events and orchestrates the pre-game flow.

```csharp
public class LobbyManager : MonoBehaviour
{
    public static LobbyManager Instance { get; private set; }

    [SerializeField] private string _gameSceneName = "GameScene";
    [SerializeField] private GlobalRules_SO _rules;

    private GameStartMsg _lastGameStartMsg;

    private void Awake()
    {
        Instance = this;
        DontDestroyOnLoad(gameObject);  // persists through scene load
    }

    private void Start()
    {
        RelayClient.Instance.OnLobbyState += OnLobbyState;
        RelayClient.Instance.OnCountdown   += OnCountdown;
        RelayClient.Instance.OnGameStart   += OnGameStart;
        RelayClient.Instance.OnError       += OnRelayError;
        RelayClient.Instance.OnDisconnected += OnDisconnected;
    }

    private void OnGameStart(GameStartMsg msg)
    {
        _lastGameStartMsg = msg;
        StartCoroutine(LoadGameScene(msg));
    }

    private IEnumerator LoadGameScene(GameStartMsg msg)
    {
        // Show loading screen
        LoadingScreen.Instance.Show("Assembling the dollhouse...");

        // Load game scene async
        AsyncOperation op = SceneManager.LoadSceneAsync(_gameSceneName);
        yield return op;

        // Scene is loaded — wire up FishNet
        StartFishNet(msg);
    }
}
```

---

## 5. FishNet Start Sequence

After the game scene loads, `LobbyManager` starts FishNet with the correct topology:

```csharp
private void StartFishNet(GameStartMsg msg)
{
    bool isHost = (RelayClient.Instance.LocalPlayerId == msg.hostId);

    // Store game_start data for GameManager to read during OnStartServer
    GameStartData.Set(msg);

    if (isHost)
    {
        // This player created the room — start as FishNet host (server + client)
        NetworkManager.ServerManager.StartConnection();
        NetworkManager.ClientManager.StartConnection("localhost");
        // "localhost" because FishNet host connects client to its own server
    }
    else
    {
        // All other players — start as FishNet client, connecting through relay
        // The relay address is the relay server URL; transport (Synapse/RelayTransport)
        // handles routing through the WebSocket
        NetworkManager.ClientManager.StartConnection(_rules.relayAddress);
    }
}
```

### 5.1 GameStartData Static Helper

`GameStartData` is a plain static class (not networked) that stores the `game_start` message for the server to read during `OnStartServer`:

```csharp
public static class GameStartData
{
    public static GameStartMsg Current { get; private set; }

    public static void Set(GameStartMsg msg) => Current = msg;
    public static void Clear() => Current = null;
}
```

`GameManager.OnStartServer()` reads `GameStartData.Current` to populate role assignments and player count without needing an RPC (the data is already available locally on the host from the relay message).

---

## 6. Player Spawning Sequence

After FishNet starts, the server spawns player NetworkObjects. This must happen AFTER the game scene is loaded AND all clients have connected. `PlayerSpawner` handles this:

```csharp
public class PlayerSpawner : NetworkBehaviour
{
    [SerializeField] private GameObject _runnerPrefab;
    [SerializeField] private GameObject _eyePrefab;
    [SerializeField] private Transform[] _runnerSpawnPoints;
    [SerializeField] private Transform   _eyeSpawnPoint;

    public override void OnStartServer()
    {
        base.OnStartServer();
        // Wait for all clients to connect before spawning
        // Use a coroutine that checks connected client count == expected player count
        StartCoroutine(WaitForAllClients());
    }

    private IEnumerator WaitForAllClients()
    {
        int expectedPlayers = GameStartData.Current.players.Length;
        float timeout = 10f;
        float elapsed = 0f;

        while (NetworkManager.ServerManager.Clients.Count < expectedPlayers && elapsed < timeout)
        {
            elapsed += Time.deltaTime;
            yield return null;
        }

        if (elapsed >= timeout)
        {
            // Timeout — start anyway with connected players
            Debug.LogWarning($"Spawn timeout: {NetworkManager.ServerManager.Clients.Count}/{expectedPlayers} connected");
        }

        SpawnAllPlayers();
    }

    [Server]
    private void SpawnAllPlayers()
    {
        var playersData = GameStartData.Current.players;
        int runnerSpawnIndex = 0;

        foreach (var p in playersData)
        {
            NetworkConnection conn = GetConnectionForRelayId(p.id);
            if (conn == null) continue;  // player disconnected before spawn

            if (p.role == "Eye")
            {
                var eyeObj = Instantiate(_eyePrefab, _eyeSpawnPoint.position, Quaternion.identity);
                ServerManager.Spawn(eyeObj, conn);
            }
            else
            {
                var spawnPoint = _runnerSpawnPoints[runnerSpawnIndex++ % _runnerSpawnPoints.Length];
                var runnerObj = Instantiate(_runnerPrefab, spawnPoint.position, Quaternion.identity);
                ServerManager.Spawn(runnerObj, conn);
            }
        }

        // Tell GameManager how many Runners spawned
        GameManager.Instance.NotifyAllSpawned(playersData.Count(p => p.role == "Runner"));
    }

    private NetworkConnection GetConnectionForRelayId(int relayPlayerId)
    {
        int fishNetId = RelayClient.Instance.RelayIdToFishNetId(relayPlayerId);
        NetworkManager.ServerManager.Clients.TryGetValue(fishNetId, out var conn);
        return conn;
    }
}
```

---

## 7. Return to Lobby After Match

When the match ends (`MatchPhase.PostMatch`), all players return to the lobby without disconnecting from the relay. `LobbyManager` handles this after the results delay:

```csharp
// LobbyManager subscribes to GameManager.OnMatchEnded:
private void OnMatchEnded(MatchResult result)
{
    StartCoroutine(ReturnToLobbyAfterResults());
}

private IEnumerator ReturnToLobbyAfterResults()
{
    yield return new WaitForSeconds(_rules.resultsDisplayTime);  // ~10 seconds

    // Stop FishNet
    if (NetworkManager.IsServerStarted)
        NetworkManager.ServerManager.StopConnection(false);
    if (NetworkManager.IsClientStarted)
        NetworkManager.ClientManager.StopConnection();

    // Clear game start data
    GameStartData.Clear();

    // Load lobby scene
    SceneManager.LoadScene(_lobbySceneName);

    // Relay host sends return_to_lobby — resets room state server-side
    if (RelayClient.Instance.IsHost)
        RelayClient.Instance.ReturnToLobby();
    // All clients receive lobby_state reset and render the lobby screen again
}
```

---

## 8. Disconnect Handling

### 8.1 Player Disconnects During Lobby

The relay removes them from the room and broadcasts `lobby_state`. `LobbyManager.OnLobbyState()` updates the UI. No special FishNet handling needed — FishNet hasn't started yet.

If the host disconnects:
- Relay promotes `players[0]` to host (per `rooms.js removePlayer()`)
- BUT the room code and join state remain valid
- Broadcast `lobby_state` with the new host
- MVP: UI shows "Room host changed to [name]"
- The new host can still start the game — their `LocalPlayerId` will now match `hostId` in the next `game_start`

**Edge case:** Host reconnects after being promoted away. Not handled in MVP — host promotion is permanent for the session.

### 8.2 Player Disconnects During Game

The relay removes them from the room and broadcasts `lobby_state`. On the FishNet side, `NetworkManager` fires `OnClientDisconnect`. `GameManager.NotifyRunnerKilled()` (or equivalent disconnect handler) decrements alive count and evaluates win conditions.

The `lobby_state` broadcast during gameplay is irrelevant — Unity clients are in the game scene and ignore JSON relay messages in game phase. The FishNet disconnect event is the authoritative signal.

### 8.3 Host Disconnects During Game

FishNet server stops. All clients disconnect from FishNet. `OnDisconnected` event fires on `RelayClient`. `LobbyManager` detects this and returns all players to the main menu with a "Host disconnected" message.

This is the MVP behavior. Alpha: implement host migration (promote first Runner's client to be the new FishNet server — complex, deferred).

---

## 9. Lobby State Rendering (Interface for SYS-29)

`LobbyManager` exposes a clean state object for the UI (SYS-29 will build the visual lobby). This is the data model the UI renders:

```csharp
public class LobbyDisplayState
{
    public string   RoomCode;
    public string   LocalPlayerName;
    public int      LocalPlayerId;
    public bool     IsHost;
    public List<LobbyPlayerEntry> Players;
    public bool     CanStart;      // true if all ready and host
    public int      CountdownSeconds; // -1 if not counting down
    public string   LobbyStatusMessage; // "Waiting for players...", "3...", etc.
}

public class LobbyPlayerEntry
{
    public int    PlayerId;
    public string Name;
    public string Role;     // "Eye" or "Runner"
    public bool   IsReady;
    public bool   IsHost;
    public bool   IsLocalPlayer;
}
```

`LobbyManager` maintains a `LobbyDisplayState` instance and fires `OnDisplayStateChanged` whenever it updates. SYS-29 subscribes to this event and re-renders the lobby UI.

---

## 10. Connection URL Configuration

The relay URL must be configurable without recompiling. Two methods:

```csharp
// Option A: Read from GlobalRules_SO (Inspector-editable)
[SerializeField] private string _relayUrl = "wss://istalk-relay.caprover.al-arcade.com";

// Option B: Environment detection
private string GetRelayUrl()
{
#if DEVELOPMENT_BUILD || UNITY_EDITOR
    return "ws://localhost:3000";
#else
    return "wss://istalk-relay.caprover.al-arcade.com";
#endif
}
```

**MVP uses Option B** — development builds connect to localhost, release builds connect to the CapRover relay. No need for a settings screen for this in MVP.

---

## 11. MVP Lobby UI Requirements (for SYS-29)

This GDD defines the functional requirements. SYS-29 defines visual design.

**Required elements:**
- Room code display (large, copyable)
- "Enter room code" input field + Join button
- Create Room button
- Player list showing: name, role badge (Eye/Runner icon), ready checkmark
- Ready toggle button (sends `set_ready`)
- Start Game button (visible and enabled only for host when all ready)
- Countdown display (3-2-1)
- Loading screen during scene transition and FishNet startup
- Error message display (relay `error` messages)

**Explicitly NOT in MVP:**
- Role swap button (swap_role support deferred)
- Browse public rooms
- Friend invites / Steam integration
- Player cards with stats/cosmetics
- Voice chat

---

## 12. RelayPlayerId → FishNetId Mapping

This is built during the connection sequence and stored on `RelayClient`:

```csharp
// Populated in StartFishNet() for each player as they connect
// Server-side: built from the order clients connect in

// For the host (local):
// RelayId = msg.hostId, FishNetId = the server's self-connection (or 0 for host-client)

// For clients: FishNet assigns connection IDs as they connect (1, 2, 3...)
// Relay player IDs are assigned in join order (1, 2, 3...)
// These SHOULD be in the same order, but to be safe:

// Each client sends their relayPlayerId to the server via a ServerRpc as soon as FishNet starts:
[ServerRpc(RequireOwnership = false)]
private void ServerRegisterRelayId(int relayPlayerId, NetworkConnection sender = null)
{
    RelayClient.Instance.RegisterMapping(relayPlayerId, sender.ClientId);
}
```

This is called in `RunnerController.OnStartClient()` / `EyeController.OnStartClient()` immediately on client connect. The server collects all mappings and `PlayerSpawner` uses them during spawn.

---

## 13. MVP Scope

- [x] Architecture correction documented: host ≠ Eye after swap_role; host = FishNet server regardless of role
- [x] MVP decision: disable swap_role UI (first joiner always Eye + host)
- [x] Full lobby flow sequence documented (create → join → ready → countdown → game_start)
- [x] LobbyManager: subscribes to RelayClient events, orchestrates flow
- [x] FishNet start: host starts server+client, clients start client only
- [x] GameStartData static helper for cross-scene data passing
- [x] PlayerSpawner: waits for all clients, spawns by role, maps relay ID to FishNet connection
- [x] Return to lobby: FishNet stop, scene reload, relay ReturnToLobby
- [x] Disconnect handling: lobby disconnect (relay handles), game disconnect (FishNet event), host disconnect (return to main menu)
- [x] LobbyDisplayState data model for SYS-29 UI
- [x] Connection URL configuration (#if DEVELOPMENT_BUILD)
- [x] MVP lobby UI requirements listed
- [x] relayPlayerId → FishNetId mapping via ServerRpc registration
- [ ] Role swap UI (Alpha)
- [ ] Public room browser (Alpha)
- [ ] Host migration (post-launch)
- [ ] Steam matchmaking / friends (Launch tier)

---

*System: SYS-21 — Lobby & Matchmaking*  
*GDD Version: 1.0*  
*Created: 2026-06-23*  
*Depends on: [sys-20-relay-transport.md](sys-20-relay-transport.md), [sys-22-match-state-machine.md](sys-22-match-state-machine.md)*  
*Status: Complete — ready for implementation*
