## NetworkManager — netfox-aware transport with graceful ENet fallback ## ## Dual-path architecture: ## Path A (netfox available) → NetworkEvents drives lifecycle signals ## Path B (headless/export) → ENetMultiplayerPeer drives lifecycle signals ## ## The server always uses ENetMultiplayerPeer as the underlying transport ## (netfox layers on top of Godot's MultiplayerAPI). When netfox NetworkEvents ## is available, it provides lifecycle signals (on_peer_join, etc.) and the ## ENet signal connections are skipped to avoid double emissions. When netfox ## is unavailable (headless mode), the built-in ENet peer_connected/ ## peer_disconnected signals are used directly. ## ## Broadcast RPCs (player spawn/despawn) work identically in both paths ## since they use Godot's built-in MultiplayerAPI, which netfox enhances ## without replacing. Round state/scores are now distributed via ## round_manager's netfox Self-RPC pattern directly. ## ## Architecture: ## server mode → start_server(port) → ENetMultiplayerPeer server ## client mode → join_server(host,port)→ ENetMultiplayerPeer client ## netfox path → NetworkEvents signals when available (editor) ## fallback → ENet peer signals in headless mode ## ## Channels (3-lane layout): ## 0 unreliable-ordered → 128Hz input / transform deltas ## 1 reliable-ordered → game events, spawn, damage, chat ## 2 unreliable → telemetry / VOIP metadata extends Node # --------------------------------------------------------------------------- # Signals # --------------------------------------------------------------------------- signal server_started(port: int) signal server_stopped() signal player_connected(peer_id: int) signal player_disconnected(peer_id: int) signal connection_succeeded() signal connection_failed(error_message: String) # --- Player replication signals (emitted on all peers after RPC broadcast) --- signal remote_player_spawned(peer_id: int, pos: Vector3) signal remote_player_despawned(peer_id: int) # --- Client prediction signals --- ## Emitted on the server when a client sends input. payload: {peer_id, tick, input_dict} signal client_input_received(peer_id: int, tick: int, input_dict: Dictionary) ## Emitted on the client when the server sends authoritative state. ## entity_id: the simulation entity this state belongs to. signal server_state_received(entity_id: int, snapshot_dict: Dictionary) # --------------------------------------------------------------------------- # Constants # --------------------------------------------------------------------------- const DEFAULT_PORT: int = 34197 const CHANNELS: int = 3 # --------------------------------------------------------------------------- # State # --------------------------------------------------------------------------- var is_server: bool = false : get = _is_server var is_client: bool = false : get = _is_client var peer: ENetMultiplayerPeer = null var max_clients: int = 16 # netfox overlay (optional — only available when netfox plugin is active) var _netfox_events = null var _netfox_events_connected: bool = false func _is_server() -> bool: return is_server func _is_client() -> bool: return is_client # --------------------------------------------------------------------------- # Lifecycle # --------------------------------------------------------------------------- func _ready() -> void: # Try to connect to netfox NetworkEvents singleton (non-critical) # We use StringName for singleton access to avoid parser errors # when netfox types aren't available (headless mode). _try_connect_netfox() func _try_connect_netfox() -> void: # Use singleton name string to avoid referencing netfox types directly if not Engine.has_singleton("NetworkEvents"): _netfox_events_connected = false print("[NetworkManager] netfox not available — using ENet directly") return var events = Engine.get_singleton("NetworkEvents") if events == null or not events.has_signal("on_server_start"): _netfox_events_connected = false print("[NetworkManager] netfox singleton found but unexpected shape — using ENet directly") return _netfox_events = events _netfox_events.on_server_start.connect(_on_netfox_server_start) _netfox_events.on_server_stop.connect(_on_netfox_server_stop) _netfox_events.on_client_start.connect(_on_netfox_client_start) _netfox_events.on_client_stop.connect(_on_netfox_client_stop) _netfox_events.on_peer_join.connect(_on_netfox_peer_join) _netfox_events.on_peer_leave.connect(_on_netfox_peer_leave) _netfox_events_connected = true print("[NetworkManager] netfox NetworkEvents overlay active") # netfox signal handlers func _on_netfox_server_start() -> void: print("[NetworkManager] Server started (netfox)") is_server = true func _on_netfox_server_stop() -> void: print("[NetworkManager] Server stopped (netfox)") is_server = false func _on_netfox_client_start(id: int) -> void: print("[NetworkManager] Client started (netfox, id=%d)" % id) is_client = true func _on_netfox_client_stop() -> void: print("[NetworkManager] Client stopped (netfox)") is_client = false func _on_netfox_peer_join(id: int) -> void: print("[NetworkManager] Peer joined (netfox, id=%d)" % id) player_connected.emit(id) func _on_netfox_peer_leave(id: int) -> void: print("[NetworkManager] Peer left (netfox, id=%d)" % id) player_disconnected.emit(id) # --------------------------------------------------------------------------- # Server API # --------------------------------------------------------------------------- func start_server(port: int = DEFAULT_PORT) -> Error: if peer and peer.get_connection_status() != MultiplayerPeer.CONNECTION_DISCONNECTED: stop() # If ServerConfig is available, use it for max_clients if ServerConfig and ServerConfig.has_method(&"get_config_path"): max_clients = ServerConfig.max_players peer = ENetMultiplayerPeer.new() peer.set_bind_ip("*") var err: Error = peer.create_server(port, max_clients, CHANNELS, 0, 0) if err != OK: peer = null return err multiplayer.multiplayer_peer = peer # Only connect ENet signals when netfox is unavailable to avoid # double-emission with NetworkEvents.on_peer_join/on_peer_leave if not _netfox_events_connected: multiplayer.multiplayer_peer.peer_connected.connect(_on_peer_connected) multiplayer.multiplayer_peer.peer_disconnected.connect(_on_peer_disconnected) print("[NetworkManager] Using ENet peer signals (netfox not available)") else: print("[NetworkManager] Using netfox NetworkEvents for lifecycle signals") is_server = true server_started.emit(port) print("[NetworkManager] Server started on port %d" % port) return OK func stop() -> void: if not peer: return if is_server: # Only disconnect ENet signals if they were connected # (when netfox was unavailable, see start_server) if not _netfox_events_connected: if multiplayer.multiplayer_peer.peer_connected.is_connected(_on_peer_connected): multiplayer.multiplayer_peer.peer_connected.disconnect(_on_peer_connected) if multiplayer.multiplayer_peer.peer_disconnected.is_connected(_on_peer_disconnected): multiplayer.multiplayer_peer.peer_disconnected.disconnect(_on_peer_disconnected) peer.close() multiplayer.multiplayer_peer = null peer = null is_server = false is_client = false server_stopped.emit() print("[NetworkManager] Stopped") # --------------------------------------------------------------------------- # Client API # --------------------------------------------------------------------------- func join_server(host: String, port: int = DEFAULT_PORT) -> Error: if peer and peer.get_connection_status() != MultiplayerPeer.CONNECTION_DISCONNECTED: stop() peer = ENetMultiplayerPeer.new() var err: Error = peer.create_client(host, port, CHANNELS, 0, 0) if err != OK: peer = null return err multiplayer.multiplayer_peer = peer connection_succeeded.emit() print("[NetworkManager] Connecting to %s:%d ..." % [host, port]) is_client = true return OK # --------------------------------------------------------------------------- # Player Replication RPCs (broadcast server → all clients) # --------------------------------------------------------------------------- @rpc("authority", "call_local", "reliable") func broadcast_spawn_player(peer_id: int, pos: Vector3, is_team_a: bool) -> void: if not multiplayer.is_server(): print("[NetworkManager] Client received spawn: peer=%d at (%.1f, %.1f)" % [peer_id, pos.x, pos.z]) remote_player_spawned.emit(peer_id, pos) @rpc("authority", "call_local", "reliable") func broadcast_despawn_player(peer_id: int) -> void: if not multiplayer.is_server(): print("[NetworkManager] Client received despawn: peer=%d" % peer_id) remote_player_despawned.emit(peer_id) # --------------------------------------------------------------------------- # Client Prediction RPCs (Phase 1 — client-side prediction) # --------------------------------------------------------------------------- ## Client → Server: send raw input for the given local tick. ## Called by ClientPrediction.on_after_tick() each physics tick. ## Uses ENet channel 0 (unreliable-ordered) for lowest-latency input delivery. @rpc("unreliable", "any_peer", "call_remote", 0) func send_client_input(tick: int, input_dict: Dictionary) -> void: if not multiplayer.is_server(): return var peer_id: int = multiplayer.get_remote_sender_id() client_input_received.emit(peer_id, tick, input_dict) ## Server → Client: send authoritative entity snapshot for reconciliation. ## Called by server-side code (e.g. GameServer after each tick). ## entity_id identifies which simulation entity this state belongs to. ## Uses ENet channel 1 (reliable-ordered) so state corrections are not dropped. @rpc("unreliable", "authority", "call_remote", 1) func send_server_state(entity_id: int, snapshot_dict: Dictionary) -> void: if multiplayer.is_server(): return server_state_received.emit(entity_id, snapshot_dict) # --------------------------------------------------------------------------- # Event handlers # --------------------------------------------------------------------------- func _on_peer_connected(id: int) -> void: print("[NetworkManager] Peer connected: %d" % id) player_connected.emit(id) func _on_peer_disconnected(id: int) -> void: print("[NetworkManager] Peer disconnected: %d" % id) player_disconnected.emit(id) func _process(_delta: float) -> void: pass func _exit_tree() -> void: stop()