# Multiplayer Grundgerüst — Design Spec

**Datum:** 2026-04-13  
**Status:** Abgenommen  
**Projekt:** Ruf der Pilze (`ruf-der-pilze/`)

---

## Kontext

Das Spiel benötigt eine Multiplayer-Basis bevor irgendeine Spiellogik gebaut werden kann. Dieses Grundgerüst schafft die Verbindungsschicht: Server startet, Clients verbinden, RPCs funktionieren. Alles Weitere (Räume, Overlays, DM-Regiepult) baut darauf auf.

---

## Entscheidungen

| Entscheidung | Wahl | Begründung |
|---|---|---|
| Transport | ENet | Native Godot UDP, geringste Latenz, kein Browser-Support benötigt |
| Dev-Server | Lokal headless | Kein VPS-Overhead während Entwicklung |
| Max. Verbindungen | 8 | 5 Spieler + 1 DM + 2 Reserve |
| Architektur | Eine Hauptszene + Autoload | Godot-idiomatisch |
| Port | 4242 | Projektstandard |
| Server-Erkennung | `--server` CLI-Argument | `OS.has_feature("dedicated_server")` gilt nur für Server-Exports, nicht für headless Dev-Runs |

---

## Dateistruktur

```
ruf-der-pilze/
├── scenes/
│   └── main.tscn               ← Root-Szene (Node)
├── scripts/
│   ├── main.gd                 ← Modus-Erkennung + Bootstrap
│   └── network_manager.gd      ← Autoload: Peer-Verwaltung, Signale
└── project.godot               ← NetworkManager als Autoload registriert
```

---

## Architektur

### NetworkManager (Autoload)

Singleton, der den gesamten Multiplayer-Peer hält. Szenen greifen über `NetworkManager` auf Verbindungsstatus und peer_ids zu.

**Verantwortlichkeiten:**
- ENetMultiplayerPeer erstellen (Server oder Client)
- Signale weiterleiten: `peer_connected(id)`, `peer_disconnected(id)`, `connected_to_server()`, `connection_failed()`
- Eigene peer_id bereitstellen: `NetworkManager.my_id` (wird bei Verbindung gesetzt)
- Liste aller verbundenen peers: `NetworkManager.peers` (Dictionary `id → {}`)

**Signale:**
```gdscript
signal peer_connected(id: int)
signal peer_disconnected(id: int)
signal connection_failed()
signal connected_to_server()
```

### main.gd — Modus-Erkennung

```gdscript
func _ready() -> void:
    # OS.has_feature("dedicated_server") gilt nur bei Server-Export-Template.
    # Für lokale Dev-Runs: --server als CLI-Argument übergeben.
    var is_server := OS.has_feature("dedicated_server") \
        or "--server" in OS.get_cmdline_user_args()

    if is_server:
        NetworkManager.start_server(4242, 8)
    else:
        NetworkManager.join_server("127.0.0.1", 4242)
```

### Server-Initialisierung

```gdscript
func start_server(port: int, max_clients: int) -> void:
    var peer := ENetMultiplayerPeer.new()
    var err := peer.create_server(port, max_clients)
    if err != OK:
        push_error("[Server] Port %d konnte nicht gebunden werden: %s" % [port, error_string(err)])
        return
    multiplayer.multiplayer_peer = peer
    multiplayer.peer_connected.connect(_on_peer_connected)
    multiplayer.peer_disconnected.connect(_on_peer_disconnected)
    print("[Server] Gestartet auf Port %d" % port)

func _on_peer_connected(id: int) -> void:
    peers[id] = {}
    peer_connected.emit(id)
    print("[Server] Peer verbunden: %d" % id)
    welcome.rpc_id(id, id)  # nur an diesen Client

func _on_peer_disconnected(id: int) -> void:
    peers.erase(id)
    peer_disconnected.emit(id)
    print("[Server] Peer getrennt: %d" % id)
```

### Client-Verbindung

```gdscript
func join_server(ip: String, port: int) -> void:
    var peer := ENetMultiplayerPeer.new()
    var err := peer.create_client(ip, port)
    if err != OK:
        push_error("[Client] Verbindung zu %s:%d fehlgeschlagen: %s" % [ip, port, error_string(err)])
        return
    multiplayer.multiplayer_peer = peer
    multiplayer.connected_to_server.connect(_on_connected_to_server)
    multiplayer.connection_failed.connect(_on_connection_failed)

func _on_connected_to_server() -> void:
    my_id = multiplayer.get_unique_id()
    connected_to_server.emit()
    print("[Client] Verbunden. Meine peer_id: %d" % my_id)

func _on_connection_failed() -> void:
    connection_failed.emit()
    push_error("[Client] Verbindung fehlgeschlagen")
```

### RPC-Smoke-Test

Server schickt nach jeder neuen Verbindung ein `welcome`-RPC **nur an diesen Client**:

```gdscript
# Hinweis: call_remote bedeutet der Server führt diese Funktion NICHT lokal aus.
# Sie wird ausschließlich auf dem Ziel-Client ausgeführt.
@rpc("authority", "call_remote", "reliable")
func welcome(peer_id: int) -> void:
    print("[Client] Willkommen, meine peer_id ist: %d" % peer_id)
```

Erwartetes Verhalten:
- **Server-Terminal:** zeigt nur `[Server] Peer verbunden: 123456`
- **Client-Output:** zeigt `[Client] Verbunden. Meine peer_id: 123456` und `[Client] Willkommen, meine peer_id ist: 123456`

---

## Verifikation

### Schritt 1 — Server starten (headless)
```bash
cd ruf-der-pilze/
godot --headless -- --server
# Erwartete Ausgabe: [Server] Gestartet auf Port 4242
```

> **Hinweis:** `--` trennt Godot-Argumente von User-Argumenten. `OS.get_cmdline_user_args()` gibt `["--server"]` zurück.

### Schritt 2 — Client verbinden (Godot Editor, F5)
```
# Server-Terminal zeigt:
[Server] Peer verbunden: 123456

# Client-Ausgabe (Godot Output-Panel):
[Client] Verbunden. Meine peer_id: 123456
[Client] Willkommen, meine peer_id ist: 123456
```

### Schritt 3 — Zweiten Client testen
Zweiten Godot-Prozess starten → Server zeigt beide peer_ids, beide Clients erhalten ihre individuelle Welcome-Nachricht mit korrekter peer_id.

### Schritt 4 — Disconnect testen
Client schließen → Server-Terminal zeigt `[Server] Peer getrennt: 123456`, `peers` Dictionary hat den Eintrag entfernt.

---

## Was dieses Grundgerüst NICHT enthält

- Spieler-Rollen (Spieler vs. DM) — kommt im nächsten Schritt
- Authentifizierung / Lobby-Logik
- Reconnect-Handling
- Irgendwelche Spiellogik

---

## Nächster Schritt

Aufbauend auf diesem Grundgerüst: **Lobby + Rollen** (Spieler registrieren sich mit Name und Rolle, DM bekommt Sonderrechte).
