extends Node # Standard Nakama Configuration var nakama_server_key = OS.get_environment("NAKAMA_SERVER_KEY") if OS.has_environment("NAKAMA_SERVER_KEY") else ProjectSettings.get_setting("network/nakama/server_key", "defaultkey") var nakama_host = OS.get_environment("NAKAMA_HOST") if OS.has_environment("NAKAMA_HOST") else ProjectSettings.get_setting("network/nakama/host", "tektondash.vps.webdock.cloud") var nakama_port = OS.get_environment("NAKAMA_PORT").to_int() if OS.has_environment("NAKAMA_PORT") else ProjectSettings.get_setting("network/nakama/port", 7350) var nakama_scheme = OS.get_environment("NAKAMA_SCHEME") if OS.has_environment("NAKAMA_SCHEME") else ProjectSettings.get_setting("network/nakama/scheme", "http") # Core Nakama Variables var client: NakamaClient var session: NakamaSession var socket: NakamaSocket var bridge: NakamaMultiplayerBridge # Signals signal connected_to_nakama signal connection_failed(error_message) signal match_joined(match_id) signal match_join_error(error_message) # Current Match Info var current_match_id: String = "" func _ready(): # Initialize the Nakama Client _init_client() # Ensure we process network events set_process(true) func _init_client(): client = Nakama.create_client(nakama_server_key, nakama_host, nakama_port, nakama_scheme) func set_server(host: String, port: int = 7350): # Clean up the host string var clean_host = host.strip_edges() # Extract protocol if present (override everything else) var forced_scheme = "" if clean_host.begins_with("https://"): forced_scheme = "https" clean_host = clean_host.replace("https://", "") elif clean_host.begins_with("http://"): forced_scheme = "http" clean_host = clean_host.replace("http://", "") # Handle trailing slashes if clean_host.ends_with("/"): clean_host = clean_host.substr(0, clean_host.length() - 1) # Extract port if explicitly provided in host string (e.g. host:port) var explicit_port = -1 if ":" in clean_host: var parts = clean_host.split(":") clean_host = parts[0] explicit_port = parts[1].to_int() # DETECT SETTINGS nakama_host = clean_host if forced_scheme != "": nakama_scheme = forced_scheme nakama_port = explicit_port if explicit_port != -1 else (443 if forced_scheme == "https" else port) elif clean_host.ends_with(".ts.net") and explicit_port == -1: # Tailscale Funnel Case (No port provided, .ts.net domain) nakama_scheme = "https" nakama_port = 443 elif clean_host.begins_with("100."): # Standard Tailscale IP Case nakama_scheme = "http" nakama_port = explicit_port if explicit_port != -1 else port else: # Generic Case (e.g. localhost, public IP) nakama_scheme = "http" nakama_port = explicit_port if explicit_port != -1 else port # Only recreate the client if there is no active session. # If we're already connected, just store the new settings for the next connect. if not session: _init_client() print("[NakamaManager] Server updated to: ", nakama_scheme, "://", nakama_host, ":", nakama_port) func _process(_delta): # If using the standard socket adapter, it needs polling in some versions if socket: pass func connect_to_nakama_async(email: String = "", password: String = "") -> bool: if is_connected_to_nakama(): print("Already connected to Nakama.") if not multiplayer.has_multiplayer_peer() and bridge: multiplayer.set_multiplayer_peer(bridge.multiplayer_peer) emit_signal("connected_to_nakama") return true # 1. Authenticate if email == "": var device_id = OS.get_unique_id() # If running in editor or debug, append a unique suffix to allow multiple # instances on one machine to have separate sessions. if OS.is_debug_build(): device_id += "_" + str(Time.get_ticks_msec()) + "_" + str(randi() % 1000) session = await client.authenticate_device_async(device_id) else: session = await client.authenticate_email_async(email, password) if session.is_exception(): var err = session.get_exception() printerr("[NakamaManager] Auth Error: %s (Code: %s)" % [err.message, err.status_code]) emit_signal("connection_failed", err.message) return false # 2. Connect Socket socket = Nakama.create_socket_from(client) var socket_result = await socket.connect_async(session) if typeof(socket_result) == TYPE_INT: if socket_result != OK: printerr("[NakamaManager] Socket Error (Code: %s)" % socket_result) emit_signal("connection_failed", "Socket connect failed with code " + str(socket_result)) return false elif socket_result.is_exception(): var err = socket_result.get_exception() var err_msg = "Socket connection failed" if err and not err.message.is_empty(): err_msg = err.message elif err and err.status_code >= 0: err_msg = "Socket connection failed with code %s" % err.status_code printerr("[NakamaManager] Socket Error: %s (Code: %s)" % [err_msg, err.status_code if err else -1]) emit_signal("connection_failed", err_msg) if socket: socket.close() socket = null return false # 3. Initialize Multiplayer Bridge # This links Nakama's socket to Godot's High-Level Multiplayer API if bridge: bridge.leave() bridge = null bridge = NakamaMultiplayerBridge.new(socket) # Connect bridge signals bridge.match_joined.connect(_on_bridge_match_joined) bridge.match_join_error.connect(_on_bridge_match_join_error) # CRITICAL: Set Godot's multiplayer peer to the Nakama bridge # This allows @rpc functions to work over Nakama multiplayer.set_multiplayer_peer(bridge.multiplayer_peer) print("Connected to Nakama and Bridge Initialized.") emit_signal("connected_to_nakama") return true func cleanup(): """Properly shutdown the Nakama connection and reset the multiplayer peer.""" print("[NakamaManager] Full cleanup starting...") if bridge: bridge.leave() bridge = null if socket: socket.close() socket = null # Delete match metadata from storage (best-effort, don't block cleanup) if session and client and not current_match_id.is_empty(): client.delete_storage_objects_async(session, [ NakamaStorageObjectId.new("match_meta", current_match_id, session.user_id) ]) current_match_id = "" # Reset Godot's multiplayer peer if multiplayer.get_multiplayer_peer(): multiplayer.set_multiplayer_peer(null) print("[NakamaManager] Cleanup complete.") # --- Match Management --- func host_game(room_meta: Dictionary = {}): if not bridge: printerr("Cannot host: Bridge not initialized") return # Guard against re-entry: create_match() rejects any state other than # DISCONNECTED. A double-click on the mode button (or a retry after a stalled # attempt) can re-enter here while a prior create_match() is still JOINING. if bridge.match_state == NakamaMultiplayerBridge.MatchState.CONNECTED: print("[NakamaManager] Already hosting a match; ignoring duplicate host request.") return if bridge.match_state != NakamaMultiplayerBridge.MatchState.DISCONNECTED: # Stranded mid-join (e.g. previous attempt never resolved). Reset so the # bridge is idle before we try again. print("[NakamaManager] Bridge busy (%s); resetting before hosting." % NakamaMultiplayerBridge.MatchState.keys()[bridge.match_state]) await bridge.leave() print("Hosting match via Nakama Bridge...") await bridge.create_match() # Store room metadata in Nakama storage so other players can see it in listings if session and current_match_id and room_meta.size() > 0: var meta_json = JSON.stringify(room_meta) var write_obj = NakamaWriteStorageObject.new( "match_meta", current_match_id, 2, 1, meta_json, "" ) var wr = await client.write_storage_objects_async(session, [write_obj]) if wr.is_exception(): push_warning("[NakamaManager] Failed to write match metadata: ", wr.get_exception().message) else: print("[NakamaManager] Match metadata stored for: ", current_match_id) func join_game(match_id: String): if not bridge: printerr("Cannot join: Bridge not initialized") return # If already connected, leave current match first if bridge.multiplayer_peer.get_connection_status() == MultiplayerPeer.CONNECTION_CONNECTED: print("Already connected to a match. Leaving current match before joining...") bridge.leave() # Wait a bit for cleanup await get_tree().create_timer(0.2).timeout print("Joining match: ", match_id) var result = await bridge.join_match(match_id) if result and result.is_exception(): emit_signal("match_join_error", result.get_exception().message) # --- Callbacks --- func _on_bridge_match_joined() -> void: current_match_id = bridge.match_id print("Successfully joined match: ", current_match_id) emit_signal("match_joined", current_match_id) func _on_bridge_match_join_error(error) -> void: printerr("Bridge failed to join match: ", error.message) emit_signal("match_join_error", error.message) func is_connected_to_nakama() -> bool: return socket != null and socket.is_connected_to_host() # --- Match Listing --- func list_matches_async(mode_filter: String = "") -> Array: """Query available matches from Nakama server. mode_filter: optional game mode string — used as label query for authoritative matches. For relayed matches this has no server-side effect; filtering is client-side.""" if not client: push_error("Cannot list matches: Client not initialized") return [] if not session or session.is_expired(): push_error("Cannot list matches: No valid session") return [] print("Querying matches from Nakama server... (filter: '%s')" % mode_filter) # Query matches — label filter works for authoritative matches only. # For relayed matches all rooms are returned; game_mode is parsed from label field if present. var result = await client.list_matches_async(session, 0, 8, 20, false, "", "") if result.is_exception(): printerr("Failed to list matches: ", result.get_exception().message) return [] var rooms: Array = [] if result.matches: print("Found %d matches" % result.matches.size()) # Build storage read requests for all matches var read_ids: Array = [] for match_data in result.matches: # Try to read metadata stored by each host read_ids.append(match_data.match_id) # Batch-read room metadata from Nakama storage var meta_map: Dictionary = {} # match_id -> {host_name, game_mode, ...} if read_ids.size() > 0: var storage_reads: Array = [] for mid in read_ids: # We don't know the owner_id, so list objects by collection+key # Use list_storage_objects per match or batch read pass # Read all match metas from our own storage (other users' objects are public read) for mid in read_ids: var list_result = await client.list_storage_objects_async(session, "match_meta", "", 100) if not list_result.is_exception() and list_result.objects: for obj in list_result.objects: var parsed = JSON.parse_string(obj.value) if parsed is Dictionary: meta_map[obj.key] = parsed break # Only need one call — lists all match_meta objects for match_data in result.matches: print(" Match: ", match_data.match_id, " - Size: ", match_data.size) var short_id = match_data.match_id.substr(0, 8) if match_data.match_id.length() > 8 else match_data.match_id # Get metadata from storage (host_name, game_mode) var meta = meta_map.get(match_data.match_id, {}) var host_name = meta.get("host_name", "Unknown") var game_mode = meta.get("game_mode", "") var max_players = int(meta.get("max_players", 8)) # Fallback: parse game_mode from label if available if game_mode.is_empty(): var label: String = match_data.label if match_data.label != null else "" if label.begins_with("[") and "]" in label: game_mode = label.substr(1, label.find("]") - 1) rooms.append({ "match_id": match_data.match_id, "room_name": short_id, "host_name": host_name, "player_count": match_data.size if match_data.size else 1, "max_players": max_players, "game_mode": game_mode }) else: print("No matches found") return rooms func _exit_tree(): cleanup()