浏览代码

Merge pull request #789 from Faless/rtc/4.0-beta4

[WebRTC] Update demos to beta4.
Fabio Alessandrelli 2 年之前
父节点
当前提交
c71e401b08

+ 3 - 3
networking/webrtc_minimal/chat.gd

@@ -8,8 +8,8 @@ var channel = peer.create_data_channel("chat", {"negotiated": true, "id": 1})
 
 func _ready():
 	# Connect all functions.
-	peer.connect(&"ice_candidate_created", self._on_ice_candidate)
-	peer.connect(&"session_description_created", self._on_session)
+	peer.ice_candidate_created.connect(_on_ice_candidate)
+	peer.session_description_created.connect(_on_session)
 
 	# Register to the local signaling server (see below for the implementation).
 	Signaling.register(String(get_path()))
@@ -36,4 +36,4 @@ func _process(delta):
 
 
 func send_message(message):
-	channel.put_packet(message.to_utf8())
+	channel.put_packet(message.to_utf8_buffer())

+ 15 - 17
networking/webrtc_minimal/main.tscn

@@ -1,30 +1,28 @@
-[gd_scene load_steps=4 format=2]
+[gd_scene load_steps=4 format=3 uid="uid://bvmm5mgb38ysa"]
 
-[ext_resource path="res://minimal.tscn" type="PackedScene" id=1]
-[ext_resource path="res://main.gd" type="Script" id=2]
-[ext_resource path="res://link_button.gd" type="Script" id=3]
+[ext_resource type="PackedScene" path="res://minimal.tscn" id="1"]
+[ext_resource type="Script" path="res://main.gd" id="2"]
+[ext_resource type="Script" path="res://link_button.gd" id="3"]
 
 [node name="Main" type="Node"]
-script = ExtResource( 2 )
+script = ExtResource("2")
 
-[node name="Minimal" parent="." instance=ExtResource( 1 )]
+[node name="Minimal" parent="." instance=ExtResource("1")]
 
 [node name="CenterContainer" type="CenterContainer" parent="."]
+anchors_preset = 15
 anchor_right = 1.0
 anchor_bottom = 1.0
-__meta__ = {
-"_edit_use_anchors_": true
-}
+grow_horizontal = 2
+grow_vertical = 2
 
 [node name="LinkButton" type="LinkButton" parent="CenterContainer"]
-offset_left = 239.0
-offset_top = 293.0
-offset_right = 785.0
-offset_bottom = 307.0
+layout_mode = 2
+offset_left = 245.0
+offset_top = 312.0
+offset_right = 906.0
+offset_bottom = 335.0
 text = "Make sure to download the GDNative WebRTC Plugin and place it in the project folder"
-script = ExtResource( 3 )
-__meta__ = {
-"_edit_use_anchors_": false
-}
+script = ExtResource("3")
 
 [connection signal="pressed" from="CenterContainer/LinkButton" to="CenterContainer/LinkButton" method="_on_LinkButton_pressed"]

+ 8 - 8
networking/webrtc_minimal/minimal.gd

@@ -10,26 +10,26 @@ var ch2 = p2.create_data_channel("chat", {"id": 1, "negotiated": true})
 func _ready():
 	print(p1.create_data_channel("chat", {"id": 1, "negotiated": true}))
 	# Connect P1 session created to itself to set local description.
-	p1.connect(&"session_description_created", p1.set_local_description)
+	p1.session_description_created.connect(p1.set_local_description)
 	# Connect P1 session and ICE created to p2 set remote description and candidates.
-	p1.connect(&"session_description_created", p2.set_remote_description)
-	p1.connect(&"ice_candidate_created", p2.add_ice_candidate)
+	p1.session_description_created.connect(p2.set_remote_description)
+	p1.ice_candidate_created.connect(p2.add_ice_candidate)
 
 	# Same for P2.
-	p2.connect(&"session_description_created", p2.set_local_description)
-	p2.connect(&"session_description_created", p1.set_remote_description)
-	p2.connect(&"ice_candidate_created", p1.add_ice_candidate)
+	p2.session_description_created.connect(p2.set_local_description)
+	p2.session_description_created.connect(p1.set_remote_description)
+	p2.ice_candidate_created.connect(p1.add_ice_candidate)
 
 	# Let P1 create the offer.
 	p1.create_offer()
 
 	# Wait a second and send message from P1.
 	await get_tree().create_timer(1).timeout
-	ch1.put_packet("Hi from P1".to_utf8())
+	ch1.put_packet("Hi from P1".to_utf8_buffer())
 
 	# Wait a second and send message from P2.
 	await get_tree().create_timer(1).timeout
-	ch2.put_packet("Hi from P2".to_utf8())
+	ch2.put_packet("Hi from P2".to_utf8_buffer())
 
 
 func _process(delta):

+ 2 - 2
networking/webrtc_minimal/project.godot

@@ -6,13 +6,14 @@
 ;   [section] ; section goes between []
 ;   param=value ; assign values to parameters
 
-config_version=4
+config_version=5
 
 [application]
 
 config/name="WebRTC Minimal Connection"
 config/description="This is a minimal sample of using WebRTC connections to connect two peers to each other."
 run/main_scene="res://main.tscn"
+config/features=PackedStringArray("4.0")
 
 [autoload]
 
@@ -20,7 +21,6 @@ Signaling="*res://Signaling.gd"
 
 [display]
 
-window/dpi/allow_hidpi=true
 window/stretch/mode="2d"
 window/stretch/aspect="expand"
 

+ 26 - 20
networking/webrtc_signaling/README.md

@@ -18,26 +18,32 @@ Check out this demo on the asset library: https://godotengine.org/asset-library/
 
 ## Protocol
 
-The protocol is text based, and composed by a command and possibly multiple payload arguments, each separated by a new line.
-
-Messages without payload must still end with a newline and are the following:
-
-- `J: ` (or `J: <ROOM>`), must be sent by client immediately after connection to get a lobby assigned or join a known one.
-  This messages is also sent by server back to the client to notify assigned lobby, or simply a successful join.
-- `I: <ID>`, sent by server to identify the client when it joins a room.
-- `N: <ID>`, sent by server to notify new peers in the same lobby.
-- `D: <ID>`, sent by server to notify when a peer in the same lobby disconnects.
-- `S: `, sent by client to seal the lobby (only the client that created it is allowed to seal a lobby).
-
-When a lobby is sealed, no new client will be able to join, and the lobby will be destroyed (and clients disconnected) after 10 seconds.
-
-Messages with payload (used to transfer WebRTC parameters) are:
-
-- `O: <ID>`, used to send an offer.
-- `A: <ID>`, used to send an answer.
-- `C: <ID>`, used to send a candidate.
-
-When sending the parameter, a client will set `<ID>` as the destination peer, the server will replace it with the id of the sending peer, and rely it to the proper destination.
+The protocol is JSON based, and uses messages in the form:
+
+```
+{
+  "id": "number",
+  "type": "number",
+  "data": "string",
+}
+```
+
+With `type` being the message type, `id` being a connected peer or `0`, and `data` being the message specific data.
+
+Messages are the following:
+
+- `0 = JOIN`, must be sent by client immediately after connection to get a lobby assigned or join a known one (via the `data` field).
+  This messages is also sent by server back to the client to notify the assigned lobby, or simply a successful join.
+- `1 = ID`, sent by server to identify the client when it joins a room (the `id` field will contain the be assigned ID).
+- `2 = PEER_CONNECT`, sent by server to notify new peers in the same lobby (the `id` field will contain the ID of the new peer).
+- `3 = PEER_DISCONNECT`, sent by server to notify when a peer in the same lobby disconnects (the `id` field will contain the ID of the disconnected peer).
+- `4 = OFFER`, sent by the client when creating a WebRTC offer then relayed back by the server to the destination peer.
+- `5 = ANSWER`, sent by the client when creating a WebRTC answer then relayed back by the server to the destination peer.
+- `6 = CANDIDATE`, sent by the client when generating new WebRTC candidates then relayed back by the server to the destination peer.
+- `7 = SEAL`, sent by client to seal the lobby (only the client that created it is allowed to seal a lobby), and then back by the server to notify success.
+  When a lobby is sealed, no new client will be able to join, and the lobby will be destroyed (and clients disconnected) after 10 seconds.
+
+For relayed messages (i.e. for `OFFER`, `ANSWER`, and `CANDIDATE`), the client will set the `id` field as the destination peer, then the server will replace it with the id of the sending peer, and send it to the proper destination.
 
 ## Screenshots
 

+ 34 - 26
networking/webrtc_signaling/client/multiplayer_client.gd

@@ -1,30 +1,32 @@
 extends "ws_webrtc_client.gd"
 
-var rtc_mp: WebRTCMultiplayer = WebRTCMultiplayer.new()
-var sealed = false
+var rtc_mp: WebRTCMultiplayerPeer = WebRTCMultiplayerPeer.new()
+var sealed := false
 
 func _init():
-	connect(&"connected", self.connected)
-	connect(&"disconnected", self.disconnected)
+	connected.connect(_connected)
+	disconnected.connect(_disconnected)
 
-	connect(&"offer_received", self.offer_received)
-	connect(&"answer_received", self.answer_received)
-	connect(&"candidate_received", self.candidate_received)
+	offer_received.connect(_offer_received)
+	answer_received.connect(_answer_received)
+	candidate_received.connect(_candidate_received)
 
-	connect(&"lobby_joined", self.lobby_joined)
-	connect(&"lobby_sealed", self.lobby_sealed)
-	connect(&"peer_connected", self.peer_connected)
-	connect(&"peer_disconnected", self.peer_disconnected)
+	lobby_joined.connect(_lobby_joined)
+	lobby_sealed.connect(_lobby_sealed)
+	peer_connected.connect(_peer_connected)
+	peer_disconnected.connect(_peer_disconnected)
 
 
-func start(url, lobby = ""):
+func start(url, lobby = "", mesh:=true):
 	stop()
 	sealed = false
+	self.mesh = mesh
 	self.lobby = lobby
 	connect_to_url(url)
 
 
 func stop():
+	multiplayer.multiplayer_peer = null
 	rtc_mp.close()
 	close()
 
@@ -34,10 +36,10 @@ func _create_peer(id):
 	peer.initialize({
 		"iceServers": [ { "urls": ["stun:stun.l.google.com:19302"] } ]
 	})
-	peer.connect(&"session_description_created", self._offer_created, [id])
-	peer.connect(&"ice_candidate_created", self._new_ice_candidate, [id])
+	peer.session_description_created.connect(_offer_created.bind(id))
+	peer.ice_candidate_created.connect(_new_ice_candidate.bind(id))
 	rtc_mp.add_peer(peer, id)
-	if id > rtc_mp.get_unique_id():
+	if id < rtc_mp.get_unique_id(): # So lobby creator never creates offers.
 		peer.create_offer()
 	return peer
 
@@ -55,46 +57,52 @@ func _offer_created(type, data, id):
 	else: send_answer(id, data)
 
 
-func connected(id):
-	print("Connected %d" % id)
-	rtc_mp.initialize(id, true)
+func _connected(id, use_mesh):
+	print("Connected %d, mesh: %s" % [id, use_mesh])
+	if use_mesh:
+		rtc_mp.create_mesh(id)
+	elif id == 1:
+		rtc_mp.create_server()
+	else:
+		rtc_mp.create_client(id)
+	multiplayer.multiplayer_peer = rtc_mp
 
 
-func lobby_joined(lobby):
+func _lobby_joined(lobby):
 	self.lobby = lobby
 
 
-func lobby_sealed():
+func _lobby_sealed():
 	sealed = true
 
 
-func disconnected():
+func _disconnected():
 	print("Disconnected: %d: %s" % [code, reason])
 	if not sealed:
 		stop() # Unexpected disconnect
 
 
-func peer_connected(id):
+func _peer_connected(id):
 	print("Peer connected %d" % id)
 	_create_peer(id)
 
 
-func peer_disconnected(id):
+func _peer_disconnected(id):
 	if rtc_mp.has_peer(id): rtc_mp.remove_peer(id)
 
 
-func offer_received(id, offer):
+func _offer_received(id, offer):
 	print("Got offer: %d" % id)
 	if rtc_mp.has_peer(id):
 		rtc_mp.get_peer(id).connection.set_remote_description("offer", offer)
 
 
-func answer_received(id, answer):
+func _answer_received(id, answer):
 	print("Got answer: %d" % id)
 	if rtc_mp.has_peer(id):
 		rtc_mp.get_peer(id).connection.set_remote_description("answer", answer)
 
 
-func candidate_received(id, mid, index, sdp):
+func _candidate_received(id, mid, index, sdp):
 	if rtc_mp.has_peer(id):
 		rtc_mp.get_peer(id).connection.add_ice_candidate(mid, index, sdp)

+ 69 - 77
networking/webrtc_signaling/client/ws_webrtc_client.gd

@@ -1,14 +1,17 @@
 extends Node
 
-@export var autojoin = true
-@export var lobby = "" # Will create a new lobby if empty.
+enum Message {JOIN, ID, PEER_CONNECT, PEER_DISCONNECT, OFFER, ANSWER, CANDIDATE, SEAL}
 
-var client: WebSocketClient = WebSocketClient.new()
+@export var autojoin := true
+@export var lobby := "" # Will create a new lobby if empty.
+@export var mesh := true # Will use the lobby host as relay otherwise.
+
+var ws: WebSocketPeer = WebSocketPeer.new()
 var code = 1000
 var reason = "Unknown"
 
 signal lobby_joined(lobby)
-signal connected(id)
+signal connected(id, use_mesh)
 signal disconnected()
 signal peer_connected(id)
 signal peer_disconnected(id)
@@ -17,113 +20,102 @@ signal answer_received(id, answer)
 signal candidate_received(id, mid, index, sdp)
 signal lobby_sealed()
 
-func _init():
-	client.connect(&"data_received", self._parse_msg)
-	client.connect(&"connection_established", self._connected)
-	client.connect(&"connection_closed", self._closed)
-	client.connect(&"connection_error", self._closed)
-	client.connect(&"server_close_request", self._close_request)
-
 
 func connect_to_url(url):
 	close()
 	code = 1000
 	reason = "Unknown"
-	client.connect_to_url(url)
+	ws.connect_to_url(url)
 
 
 func close():
-	client.disconnect_from_host()
-
-
-func _closed(was_clean = false):
-	emit_signal("disconnected")
-
-
-func _close_request(code, reason):
-	self.code = code
-	self.reason = reason
+	ws.close()
 
 
-func _connected(protocol = ""):
-	client.get_peer(1).set_write_mode(WebSocketPeer.WRITE_MODE_TEXT)
-	if autojoin:
+func _process(delta):
+	var old_state: int = ws.get_ready_state()
+	if old_state == WebSocketPeer.STATE_CLOSED:
+		return
+	ws.poll()
+	var state = ws.get_ready_state()
+	if state != old_state and state == WebSocketPeer.STATE_OPEN and autojoin:
 		join_lobby(lobby)
+	while state == WebSocketPeer.STATE_OPEN and ws.get_available_packet_count():
+		if not _parse_msg():
+			print("Error parsing message from server.")
+	if state == WebSocketPeer.STATE_CLOSED:
+		code = ws.get_close_code()
+		reason = ws.get_close_reason()
+		disconnected.emit()
 
 
 func _parse_msg():
-	var pkt_str: String = client.get_peer(1).get_packet().get_string_from_utf8()
-
-	var req: PackedStringArray = pkt_str.split("\n", true, 1)
-	if req.size() != 2: # Invalid request size
-		return
-
-	var type: String = req[0]
-	if type.length() < 3: # Invalid type size
-		return
-
-	if type.begins_with("J: "):
-		emit_signal("lobby_joined", type.substr(3, type.length() - 3))
-		return
-	elif type.begins_with("S: "):
-		emit_signal("lobby_sealed")
-		return
-
-	var src_str: String = type.substr(3, type.length() - 3)
-	if not src_str.is_valid_int(): # Source id is not an integer
-		return
-
-	var src_id: int = int(src_str)
-
-	if type.begins_with("I: "):
-		emit_signal("connected", src_id)
-	elif type.begins_with("N: "):
+	var parsed = JSON.parse_string(ws.get_packet().get_string_from_utf8())
+	if typeof(parsed) != TYPE_DICTIONARY or not parsed.has("type") or not parsed.has("id") or \
+		typeof(parsed.get("data")) != TYPE_STRING:
+		return false
+
+	var msg := parsed as Dictionary
+	if not str(msg.type).is_valid_int() or not str(msg.id).is_valid_int():
+		return false
+
+	var type := str(msg.type).to_int()
+	var src_id := str(msg.id).to_int()
+
+	if type == Message.ID:
+		connected.emit(src_id, msg.data == "true")
+	elif type == Message.JOIN:
+		lobby_joined.emit(msg.data)
+	elif type == Message.SEAL:
+		lobby_sealed.emit()
+	elif type == Message.PEER_CONNECT:
 		# Client connected
-		emit_signal("peer_connected", src_id)
-	elif type.begins_with("D: "):
+		peer_connected.emit(src_id)
+	elif type == Message.PEER_DISCONNECT:
 		# Client connected
-		emit_signal("peer_disconnected", src_id)
-	elif type.begins_with("O: "):
+		peer_disconnected.emit(src_id)
+	elif type == Message.OFFER:
 		# Offer received
-		emit_signal("offer_received", src_id, req[1])
-	elif type.begins_with("A: "):
+		offer_received.emit(src_id, msg.data)
+	elif type == Message.ANSWER:
 		# Answer received
-		emit_signal("answer_received", src_id, req[1])
-	elif type.begins_with("C: "):
+		answer_received.emit(src_id, msg.data)
+	elif type == Message.CANDIDATE:
 		# Candidate received
-		var candidate: PackedStringArray = req[1].split("\n", false)
+		var candidate: PackedStringArray = msg.data.split("\n", false)
 		if candidate.size() != 3:
-			return
+			return false
 		if not candidate[1].is_valid_int():
-			return
-		emit_signal("candidate_received", src_id, candidate[0], int(candidate[1]), candidate[2])
+			return false
+		candidate_received.emit(src_id, candidate[0], candidate[1].to_int(), candidate[2])
+	else:
+		return false
+	return true # Parsed
 
 
-func join_lobby(lobby):
-	return client.get_peer(1).put_packet(("J: %s\n" % lobby).to_utf8())
+func join_lobby(lobby: String):
+	return _send_msg(Message.JOIN, 0 if mesh else 1, lobby)
 
 
 func seal_lobby():
-	return client.get_peer(1).put_packet("S: \n".to_utf8())
+	return _send_msg(Message.SEAL, 0)
 
 
 func send_candidate(id, mid, index, sdp) -> int:
-	return _send_msg("C", id, "\n%s\n%d\n%s" % [mid, index, sdp])
+	return _send_msg(Message.CANDIDATE, id, "\n%s\n%d\n%s" % [mid, index, sdp])
 
 
 func send_offer(id, offer) -> int:
-	return _send_msg("O", id, offer)
+	return _send_msg(Message.OFFER, id, offer)
 
 
 func send_answer(id, answer) -> int:
-	return _send_msg("A", id, answer)
-
+	return _send_msg(Message.ANSWER, id, answer)
 
-func _send_msg(type, id, data) -> int:
-	return client.get_peer(1).put_packet(("%s: %d\n%s" % [type, id, data]).to_utf8())
 
-
-func _process(delta):
-	var status: int = client.get_connection_status()
-	if status == WebSocketClient.CONNECTION_CONNECTING or status == WebSocketClient.CONNECTION_CONNECTED:
-		client.poll()
+func _send_msg(type: int, id: int, data:="") -> int:
+	return ws.send_text(JSON.stringify({
+		"type": type,
+		"id": id,
+		"data": data
+	}))

+ 42 - 40
networking/webrtc_signaling/demo/client_ui.gd

@@ -1,54 +1,58 @@
 extends Control
 
 @onready var client = $Client
+@onready var host = $VBoxContainer/Connect/Host
+@onready var room = $VBoxContainer/Connect/RoomSecret
+@onready var mesh = $VBoxContainer/Connect/Mesh
 
 func _ready():
-	client.connect(&"lobby_joined", self._lobby_joined)
-	client.connect(&"lobby_sealed", self._lobby_sealed)
-	client.connect(&"connected", self._connected)
-	client.connect(&"disconnected", self._disconnected)
-	client.rtc_mp.connect(&"peer_connected", self._mp_peer_connected)
-	client.rtc_mp.connect(&"peer_disconnected", self._mp_peer_disconnected)
-	client.rtc_mp.connect(&"server_disconnected", self._mp_server_disconnect)
-	client.rtc_mp.connect(&"connection_succeeded", self._mp_connected)
+	client.lobby_joined.connect(_lobby_joined)
+	client.lobby_sealed.connect(_lobby_sealed)
+	client.connected.connect(_connected)
+	client.disconnected.connect(_disconnected)
 
+	multiplayer.connected_to_server.connect(_mp_server_connected)
+	multiplayer.connection_failed.connect(_mp_server_disconnect)
+	multiplayer.server_disconnected.connect(_mp_server_disconnect)
+	multiplayer.peer_connected.connect(_mp_peer_connected)
+	multiplayer.peer_disconnected.connect(_mp_peer_disconnected)
 
-func _process(delta):
-	client.rtc_mp.poll()
-	while client.rtc_mp.get_available_packet_count() > 0:
-		_log(client.rtc_mp.get_packet().get_string_from_utf8())
 
+@rpc(any_peer, call_local)
+func ping(argument):
+	_log("[Multiplayer] Ping from peer %d: arg: %s" % [multiplayer.get_remote_sender_id(), argument])
 
-func _connected(id):
-	_log("Signaling server connected with ID: %d" % id)
 
+func _mp_server_connected():
+	_log("[Multiplayer] Server connected (I am %d)" % client.rtc_mp.get_unique_id())
 
-func _disconnected():
-	_log("Signaling server disconnected: %d - %s" % [client.code, client.reason])
 
+func _mp_server_disconnect():
+	_log("[Multiplayer] Server disconnected (I am %d)" % client.rtc_mp.get_unique_id())
 
-func _lobby_joined(lobby):
-	_log("Joined lobby %s" % lobby)
 
+func _mp_peer_connected(id: int):
+	_log("[Multiplayer] Peer %d connected" % id)
 
-func _lobby_sealed():
-	_log("Lobby has been sealed")
 
+func _mp_peer_disconnected(id: int):
+	_log("[Multiplayer] Peer %d disconnected" % id)
 
-func _mp_connected():
-	_log("Multiplayer is connected (I am %d)" % client.rtc_mp.get_unique_id())
 
+func _connected(id):
+	_log("[Signaling] Server connected with ID: %d" % id)
 
-func _mp_server_disconnect():
-	_log("Multiplayer is disconnected (I am %d)" % client.rtc_mp.get_unique_id())
 
+func _disconnected():
+	_log("[Signaling] Server disconnected: %d - %s" % [client.code, client.reason])
 
-func _mp_peer_connected(id: int):
-	_log("Multiplayer peer %d connected" % id)
 
+func _lobby_joined(lobby):
+	_log("[Signaling] Joined lobby %s" % lobby)
 
-func _mp_peer_disconnected(id: int):
-	_log("Multiplayer peer %d disconnected" % id)
+
+func _lobby_sealed():
+	_log("[Signaling] Lobby has been sealed")
 
 
 func _log(msg):
@@ -56,24 +60,22 @@ func _log(msg):
 	$VBoxContainer/TextEdit.text += str(msg) + "\n"
 
 
-func ping():
-	_log(client.rtc_mp.put_packet("ping".to_utf8()))
+func _on_peers_pressed():
+	_log(multiplayer.get_peers())
 
 
-func _on_Peers_pressed():
-	var d = client.rtc_mp.get_peers()
-	_log(d)
-	for k in d:
-		_log(client.rtc_mp.get_peer(k))
+func _on_ping_pressed():
+	randomize()
+	ping.rpc(randf())
 
 
-func start():
-	client.start($VBoxContainer/Connect/Host.text, $VBoxContainer/Connect/RoomSecret.text)
+func _on_seal_pressed():
+	client.seal_lobby()
 
 
-func _on_Seal_pressed():
-	client.seal_lobby()
+func _on_start_pressed():
+	client.start(host.text, room.text, mesh.button_pressed)
 
 
-func stop():
+func _on_stop_pressed():
 	client.stop()

+ 65 - 52
networking/webrtc_signaling/demo/client_ui.tscn

@@ -1,107 +1,120 @@
-[gd_scene load_steps=3 format=2]
+[gd_scene load_steps=3 format=3 uid="uid://cpwp4xx6mv5p"]
 
-[ext_resource path="res://demo/client_ui.gd" type="Script" id=1]
-[ext_resource path="res://client/multiplayer_client.gd" type="Script" id=2]
+[ext_resource type="Script" path="res://demo/client_ui.gd" id="1"]
+[ext_resource type="Script" path="res://client/multiplayer_client.gd" id="2"]
 
 [node name="ClientUI" type="Control"]
+layout_mode = 3
+anchors_preset = 0
 offset_right = 1024.0
 offset_bottom = 600.0
 size_flags_horizontal = 3
 size_flags_vertical = 3
-script = ExtResource( 1 )
-__meta__ = {
-"_edit_use_anchors_": true
-}
+script = ExtResource("1")
 
 [node name="Client" type="Node" parent="."]
-script = ExtResource( 2 )
+script = ExtResource("2")
 
 [node name="VBoxContainer" type="VBoxContainer" parent="."]
+layout_mode = 1
+anchors_preset = 15
 anchor_right = 1.0
 anchor_bottom = 1.0
-custom_constants/separation = 8
-__meta__ = {
-"_edit_use_anchors_": false
-}
+grow_horizontal = 2
+grow_vertical = 2
 
 [node name="Connect" type="HBoxContainer" parent="VBoxContainer"]
+layout_mode = 2
 offset_right = 1024.0
-offset_bottom = 24.0
+offset_bottom = 31.0
 
 [node name="Label" type="Label" parent="VBoxContainer/Connect"]
-offset_top = 5.0
-offset_right = 73.0
-offset_bottom = 19.0
+layout_mode = 2
+offset_top = 2.0
+offset_right = 89.0
+offset_bottom = 28.0
 text = "Connect to:"
 
 [node name="Host" type="LineEdit" parent="VBoxContainer/Connect"]
-offset_left = 77.0
-offset_right = 921.0
-offset_bottom = 24.0
+layout_mode = 2
+offset_left = 93.0
+offset_right = 829.0
+offset_bottom = 31.0
 size_flags_horizontal = 3
 text = "ws://localhost:9080"
 
 [node name="Room" type="Label" parent="VBoxContainer/Connect"]
-offset_left = 925.0
-offset_right = 962.0
-offset_bottom = 24.0
+layout_mode = 2
+offset_left = 833.0
+offset_right = 879.0
+offset_bottom = 31.0
 size_flags_vertical = 5
 text = "Room"
-valign = 1
 
 [node name="RoomSecret" type="LineEdit" parent="VBoxContainer/Connect"]
-offset_left = 966.0
-offset_right = 1024.0
-offset_bottom = 24.0
+layout_mode = 2
+offset_left = 883.0
+offset_right = 950.0
+offset_bottom = 31.0
 placeholder_text = "secret"
 
+[node name="Mesh" type="CheckBox" parent="VBoxContainer/Connect"]
+layout_mode = 2
+offset_left = 954.0
+offset_right = 1024.0
+offset_bottom = 31.0
+button_pressed = true
+text = "Mesh"
+
 [node name="HBoxContainer" type="HBoxContainer" parent="VBoxContainer"]
-offset_top = 32.0
+layout_mode = 2
+offset_top = 35.0
 offset_right = 1024.0
-offset_bottom = 52.0
-custom_constants/separation = 10
-__meta__ = {
-"_edit_use_anchors_": false
-}
+offset_bottom = 66.0
 
 [node name="Start" type="Button" parent="VBoxContainer/HBoxContainer"]
-offset_right = 41.0
-offset_bottom = 20.0
+layout_mode = 2
+offset_right = 46.0
+offset_bottom = 31.0
 text = "Start"
 
 [node name="Stop" type="Button" parent="VBoxContainer/HBoxContainer"]
-offset_left = 51.0
-offset_right = 91.0
-offset_bottom = 20.0
+layout_mode = 2
+offset_left = 50.0
+offset_right = 93.0
+offset_bottom = 31.0
 text = "Stop"
 
 [node name="Seal" type="Button" parent="VBoxContainer/HBoxContainer"]
-offset_left = 101.0
-offset_right = 139.0
-offset_bottom = 20.0
+layout_mode = 2
+offset_left = 97.0
+offset_right = 137.0
+offset_bottom = 31.0
 text = "Seal"
 
 [node name="Ping" type="Button" parent="VBoxContainer/HBoxContainer"]
-offset_left = 149.0
-offset_right = 188.0
-offset_bottom = 20.0
+layout_mode = 2
+offset_left = 141.0
+offset_right = 183.0
+offset_bottom = 31.0
 text = "Ping"
 
 [node name="Peers" type="Button" parent="VBoxContainer/HBoxContainer"]
-offset_left = 198.0
+layout_mode = 2
+offset_left = 187.0
 offset_right = 280.0
-offset_bottom = 20.0
+offset_bottom = 31.0
 text = "Print peers"
 
 [node name="TextEdit" type="TextEdit" parent="VBoxContainer"]
-offset_top = 60.0
+layout_mode = 2
+offset_top = 70.0
 offset_right = 1024.0
 offset_bottom = 600.0
 size_flags_vertical = 3
-readonly = true
 
-[connection signal="pressed" from="VBoxContainer/HBoxContainer/Start" to="." method="start"]
-[connection signal="pressed" from="VBoxContainer/HBoxContainer/Stop" to="." method="stop"]
-[connection signal="pressed" from="VBoxContainer/HBoxContainer/Seal" to="." method="_on_Seal_pressed"]
-[connection signal="pressed" from="VBoxContainer/HBoxContainer/Ping" to="." method="ping"]
-[connection signal="pressed" from="VBoxContainer/HBoxContainer/Peers" to="." method="_on_Peers_pressed"]
+[connection signal="pressed" from="VBoxContainer/HBoxContainer/Start" to="." method="_on_start_pressed"]
+[connection signal="pressed" from="VBoxContainer/HBoxContainer/Stop" to="." method="_on_stop_pressed"]
+[connection signal="pressed" from="VBoxContainer/HBoxContainer/Seal" to="." method="_on_seal_pressed"]
+[connection signal="pressed" from="VBoxContainer/HBoxContainer/Ping" to="." method="_on_ping_pressed"]
+[connection signal="pressed" from="VBoxContainer/HBoxContainer/Peers" to="." method="_on_peers_pressed"]

+ 8 - 0
networking/webrtc_signaling/demo/main.gd

@@ -1,5 +1,13 @@
 extends Control
 
+func _enter_tree():
+	for c in $VBoxContainer/Clients.get_children():
+		# So each child gets its own separate MultiplayerAPI.
+		get_tree().set_multiplayer(
+			MultiplayerAPI.create_default_interface(),
+			NodePath("%s/VBoxContainer/Clients/%s" % [get_path(), c.name])
+		)
+
 func _ready():
 	if OS.get_name() == "HTML5":
 		$VBoxContainer/Signaling.hide()

+ 47 - 54
networking/webrtc_signaling/demo/main.tscn

@@ -1,100 +1,93 @@
-[gd_scene load_steps=4 format=2]
+[gd_scene load_steps=4 format=3 uid="uid://5p1bp2kcs0py"]
 
-[ext_resource path="res://demo/main.gd" type="Script" id=1]
-[ext_resource path="res://demo/client_ui.tscn" type="PackedScene" id=2]
-[ext_resource path="res://server/ws_webrtc_server.gd" type="Script" id=3]
+[ext_resource type="Script" path="res://demo/main.gd" id="1"]
+[ext_resource type="PackedScene" uid="uid://cpwp4xx6mv5p" path="res://demo/client_ui.tscn" id="2"]
+[ext_resource type="Script" path="res://server/ws_webrtc_server.gd" id="3"]
 
 [node name="Control" type="Control"]
+layout_mode = 3
 anchor_left = 0.0136719
 anchor_top = 0.0166667
 anchor_right = 0.986328
 anchor_bottom = 0.983333
-offset_top = 4.32134e-07
-offset_bottom = -9.53674e-06
-script = ExtResource( 1 )
-__meta__ = {
-"_edit_use_anchors_": true
-}
+script = ExtResource("1")
 
 [node name="VBoxContainer" type="VBoxContainer" parent="."]
+anchors_preset = 15
 anchor_right = 1.0
 anchor_bottom = 1.0
-custom_constants/separation = 50
-__meta__ = {
-"_edit_use_anchors_": true
-}
+grow_horizontal = 2
+grow_vertical = 2
 
 [node name="Signaling" type="HBoxContainer" parent="VBoxContainer"]
-offset_right = 995.0
-offset_bottom = 24.0
+offset_right = 1120.0
+offset_bottom = 31.0
 
 [node name="Label" type="Label" parent="VBoxContainer/Signaling"]
-offset_top = 5.0
-offset_right = 104.0
-offset_bottom = 19.0
+offset_top = 2.0
+offset_right = 127.0
+offset_bottom = 28.0
 text = "Signaling server:"
 
 [node name="Port" type="SpinBox" parent="VBoxContainer/Signaling"]
-offset_left = 108.0
-offset_right = 182.0
-offset_bottom = 24.0
+offset_left = 131.0
+offset_right = 214.0
+offset_bottom = 31.0
 min_value = 1025.0
 max_value = 65535.0
 value = 9080.0
 
 [node name="ListenButton" type="Button" parent="VBoxContainer/Signaling"]
-offset_left = 186.0
-offset_right = 237.0
-offset_bottom = 24.0
+offset_left = 218.0
+offset_right = 273.0
+offset_bottom = 31.0
 toggle_mode = true
 text = "Listen"
 
 [node name="CenterContainer" type="CenterContainer" parent="VBoxContainer/Signaling"]
-offset_left = 241.0
-offset_right = 995.0
-offset_bottom = 24.0
+offset_left = 277.0
+offset_right = 1120.0
+offset_bottom = 31.0
 size_flags_horizontal = 3
 size_flags_vertical = 3
 
 [node name="LinkButton" type="LinkButton" parent="VBoxContainer/Signaling/CenterContainer"]
-offset_left = 104.0
-offset_top = 5.0
-offset_right = 650.0
-offset_bottom = 19.0
+offset_left = 91.0
+offset_top = 4.0
+offset_right = 752.0
+offset_bottom = 27.0
 text = "Make sure to download the GDNative WebRTC Plugin and place it in the project folder"
 
 [node name="Clients" type="GridContainer" parent="VBoxContainer"]
-offset_top = 74.0
-offset_right = 995.0
-offset_bottom = 579.0
+offset_top = 35.0
+offset_right = 1120.0
+offset_bottom = 626.0
 size_flags_horizontal = 3
 size_flags_vertical = 3
-custom_constants/vseparation = 15
-custom_constants/hseparation = 15
 columns = 2
 
-[node name="ClientUI" parent="VBoxContainer/Clients" instance=ExtResource( 2 )]
-offset_right = 490.0
-offset_bottom = 245.0
+[node name="ClientUI" parent="VBoxContainer/Clients" instance=ExtResource("2")]
+offset_right = 558.0
+offset_bottom = 294.0
 
-[node name="ClientUI2" parent="VBoxContainer/Clients" instance=ExtResource( 2 )]
-offset_left = 505.0
-offset_right = 995.0
-offset_bottom = 245.0
+[node name="ClientUI2" parent="VBoxContainer/Clients" instance=ExtResource("2")]
+offset_left = 562.0
+offset_right = 1120.0
+offset_bottom = 294.0
 
-[node name="ClientUI3" parent="VBoxContainer/Clients" instance=ExtResource( 2 )]
-offset_top = 260.0
-offset_right = 490.0
-offset_bottom = 505.0
+[node name="ClientUI3" parent="VBoxContainer/Clients" instance=ExtResource("2")]
+offset_top = 298.0
+offset_right = 558.0
+offset_bottom = 591.0
 
-[node name="ClientUI4" parent="VBoxContainer/Clients" instance=ExtResource( 2 )]
-offset_left = 505.0
-offset_top = 260.0
-offset_right = 995.0
-offset_bottom = 505.0
+[node name="ClientUI4" parent="VBoxContainer/Clients" instance=ExtResource("2")]
+offset_left = 562.0
+offset_top = 298.0
+offset_right = 1120.0
+offset_bottom = 591.0
 
 [node name="Server" type="Node" parent="."]
-script = ExtResource( 3 )
+script = ExtResource("3")
 
 [connection signal="toggled" from="VBoxContainer/Signaling/ListenButton" to="." method="_on_listen_toggled"]
 [connection signal="pressed" from="VBoxContainer/Signaling/CenterContainer/LinkButton" to="." method="_on_LinkButton_pressed"]

+ 3 - 3
networking/webrtc_signaling/project.godot

@@ -6,7 +6,7 @@
 ;   [section] ; section goes between []
 ;   param=value ; assign values to parameters
 
-config_version=4
+config_version=5
 
 [application]
 
@@ -16,16 +16,16 @@ This demo is devided in 4 parts.
 The protocol is text based, and composed by a command and possibly
 multiple payload arguments, each separated by a new line."
 run/main_scene="res://demo/main.tscn"
+config/features=PackedStringArray("4.0")
 
 [debug]
 
 gdscript/warnings/shadowed_variable=false
-gdscript/warnings/unused_argument=false
 gdscript/warnings/return_value_discarded=false
+gdscript/warnings/unused_argument=false
 
 [display]
 
-window/dpi/allow_hidpi=true
 window/stretch/mode="2d"
 window/stretch/aspect="expand"
 

+ 118 - 107
networking/webrtc_signaling/server/ws_webrtc_server.gd

@@ -1,210 +1,221 @@
 extends Node
 
+enum Message {JOIN, ID, PEER_CONNECT, PEER_DISCONNECT, OFFER, ANSWER, CANDIDATE, SEAL}
+
 const TIMEOUT = 1000 # Unresponsive clients times out after 1 sec
 const SEAL_TIME = 10000 # A sealed room will be closed after this time
 const ALFNUM = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
 
-var _alfnum = ALFNUM.to_ascii()
+var _alfnum = ALFNUM.to_ascii_buffer()
 
 var rand: RandomNumberGenerator = RandomNumberGenerator.new()
 var lobbies: Dictionary = {}
-var server: WebSocketServer = WebSocketServer.new()
+var tcp_server := TCPServer.new()
 var peers: Dictionary = {}
 
 class Peer extends RefCounted:
 	var id = -1
 	var lobby = ""
-	var time = OS.get_ticks_msec()
+	var time = Time.get_ticks_msec()
+	var ws = WebSocketPeer.new()
+
 
-	func _init(peer_id):
+	func _init(peer_id, tcp):
 		id = peer_id
+		ws.accept_stream(tcp)
+
+
+	func is_ws_open() -> bool:
+		return ws.get_ready_state() == WebSocketPeer.STATE_OPEN
 
 
+	func send(type: int, id: int, data:=""):
+		return ws.send_text(JSON.stringify({
+			"type": type,
+			"id": id,
+			"data": data,
+		}))
+
 
 class Lobby extends RefCounted:
-	var peers: Array = []
+	var peers: = {}
 	var host: int = -1
 	var sealed: bool = false
 	var time = 0
+	var mesh := true
 
-	func _init(host_id: int):
+	func _init(host_id: int, use_mesh: bool):
 		host = host_id
+		mesh = use_mesh
 
-	func join(peer_id, server) -> bool:
+	func join(peer: Peer) -> bool:
 		if sealed: return false
-		if not server.has_peer(peer_id): return false
-		var new_peer: WebSocketPeer = server.get_peer(peer_id)
-		new_peer.put_packet(("I: %d\n" % (1 if peer_id == host else peer_id)).to_utf8())
-		for p in peers:
-			if not server.has_peer(p):
+		if not peer.is_ws_open(): return false
+		peer.send(Message.ID, (1 if peer.id == host else peer.id), "true" if mesh else "")
+		for p in peers.values():
+			if not p.is_ws_open():
+				continue
+			if not mesh and p.id != host:
+				# Only host is visible when using client-server
 				continue
-			server.get_peer(p).put_packet(("N: %d\n" % peer_id).to_utf8())
-			new_peer.put_packet(("N: %d\n" % (1 if p == host else p)).to_utf8())
-		peers.push_back(peer_id)
+			p.send(Message.PEER_CONNECT, peer.id)
+			peer.send(Message.PEER_CONNECT, (1 if p.id == host else p.id))
+		peers[peer.id] = peer
 		return true
 
 
-	func leave(peer_id, server) -> bool:
-		if not peers.has(peer_id): return false
-		peers.erase(peer_id)
+	func leave(peer: Peer) -> bool:
+		if not peers.has(peer.id): return false
+		peers.erase(peer.id)
 		var close = false
-		if peer_id == host:
+		if peer.id == host:
 			# The room host disconnected, will disconnect all peers.
 			close = true
 		if sealed: return close
 		# Notify other peers.
-		for p in peers:
-			if not server.has_peer(p): return close
+		for p in peers.values():
+			if not p.is_ws_open():
+				continue
 			if close:
 				# Disconnect peers.
-				server.disconnect_peer(p)
+				p.ws.close()
 			else:
 				# Notify disconnection.
-				server.get_peer(p).put_packet(("D: %d\n" % peer_id).to_utf8())
+				p.send(Message.PEER_DISCONNECT, peer.id)
 		return close
 
 
-	func seal(peer_id, server) -> bool:
+	func seal(peer_id: int) -> bool:
 		# Only host can seal the room.
 		if host != peer_id: return false
 		sealed = true
-		for p in peers:
-			server.get_peer(p).put_packet("S: \n".to_utf8())
-		time = OS.get_ticks_msec()
+		for p in peers.values():
+			if not p.is_ws_open():
+				continue
+			p.send(Message.SEAL, 0)
+		time = Time.get_ticks_msec()
+		peers.clear()
 		return true
 
 
-
-func _init():
-	server.connect(&"data_received", self._on_data)
-	server.connect(&"client_connected", self._peer_connected)
-	server.connect(&"client_disconnected", self._peer_disconnected)
-
-
 func _process(delta):
 	poll()
 
 
 func listen(port):
 	stop()
-	rand.seed = OS.get_unix_time()
-	server.listen(port)
+	rand.seed = Time.get_unix_time_from_system()
+	tcp_server.listen(port)
 
 
 func stop():
-	server.stop()
+	tcp_server.stop()
 	peers.clear()
 
 
 func poll():
-	if not server.is_listening():
+	if not tcp_server.is_listening():
 		return
 
-	server.poll()
+	if tcp_server.is_connection_available():
+		var id = randi() % (1 << 31)
+		peers[id] = Peer.new(id, tcp_server.take_connection())
 
-	# Peers timeout.
+	# Poll peers.
+	var to_remove := []
 	for p in peers.values():
-		if p.lobby == "" and OS.get_ticks_msec() - p.time > TIMEOUT:
-			server.disconnect_peer(p.id)
+		# Peers timeout.
+		if p.lobby == "" and Time.get_ticks_msec() - p.time > TIMEOUT:
+			p.ws.close()
+		p.ws.poll()
+		while p.is_ws_open() and p.ws.get_available_packet_count():
+			if not _parse_msg(p):
+				print("Parse message failed from peer %d" % p.id)
+				to_remove.push_back(p.id)
+				p.ws.close()
+				break
+		var state = p.ws.get_ready_state()
+		if state == WebSocketPeer.STATE_CLOSED:
+			print("Peer %d disconnected from lobby: '%s'" % [p.id, p.lobby])
+			# Remove from lobby (and lobby itself if host).
+			if lobbies.has(p.lobby) and lobbies[p.lobby].leave(p):
+				print("Deleted lobby %s" % p.lobby)
+				lobbies.erase(p.lobby)
+			# Remove from peers
+			to_remove.push_back(p.id)
+
 	# Lobby seal.
 	for k in lobbies:
 		if not lobbies[k].sealed:
 			continue
-		if lobbies[k].time + SEAL_TIME < OS.get_ticks_msec():
+		if lobbies[k].time + SEAL_TIME < Time.get_ticks_msec():
 			# Close lobby.
 			for p in lobbies[k].peers:
-				server.disconnect_peer(p)
-
-
-func _peer_connected(id, protocol = ""):
-	peers[id] = Peer.new(id)
+				p.ws.close()
+				to_remove.push_back(p.id)
 
+	# Remove stale peers
+	for id in to_remove:
+		peers.erase(id)
 
-func _peer_disconnected(id, was_clean = false):
-	var lobby = peers[id].lobby
-	print("Peer %d disconnected from lobby: '%s'" % [id, lobby])
-	if lobby and lobbies.has(lobby):
-		peers[id].lobby = ""
-		if lobbies[lobby].leave(id, server):
-			# If true, lobby host has disconnected, so delete it.
-			print("Deleted lobby %s" % lobby)
-			lobbies.erase(lobby)
-	peers.erase(id)
 
-
-func _join_lobby(peer, lobby) -> bool:
+func _join_lobby(peer: Peer, lobby: String, mesh: bool) -> bool:
 	if lobby == "":
 		for _i in range(0, 32):
 			lobby += char(_alfnum[rand.randi_range(0, ALFNUM.length()-1)])
-		lobbies[lobby] = Lobby.new(peer.id)
+		lobbies[lobby] = Lobby.new(peer.id, mesh)
 	elif not lobbies.has(lobby):
 		return false
-	lobbies[lobby].join(peer.id, server)
+	lobbies[lobby].join(peer)
 	peer.lobby = lobby
 	# Notify peer of its lobby
-	server.get_peer(peer.id).put_packet(("J: %s\n" % lobby).to_utf8())
+	peer.send(Message.JOIN, 0, lobby)
 	print("Peer %d joined lobby: '%s'" % [peer.id, lobby])
 	return true
 
 
-func _on_data(id):
-	if not _parse_msg(id):
-		print("Parse message failed from peer %d" % id)
-		server.disconnect_peer(id)
-
-
-func _parse_msg(id) -> bool:
-	var pkt_str: String = server.get_peer(id).get_packet().get_string_from_utf8()
-
-	var req = pkt_str.split("\n", true, 1)
-	if req.size() != 2: # Invalid request size
+func _parse_msg(peer: Peer) -> bool:
+	var pkt_str: String = peer.ws.get_packet().get_string_from_utf8()
+	var parsed = JSON.parse_string(pkt_str)
+	if typeof(parsed) != TYPE_DICTIONARY or not parsed.has("type") or not parsed.has("id") or \
+		typeof(parsed.get("data")) != TYPE_STRING:
 		return false
-
-	var type = req[0]
-	if type.length() < 3: # Invalid type size
+	if not str(parsed.type).is_valid_int() or not str(parsed.id).is_valid_int():
 		return false
 
-	if type.begins_with("J: "):
-		if peers[id].lobby: # Peer must not have joined a lobby already!
-			return false
-		return _join_lobby(peers[id], type.substr(3, type.length() - 3))
+	var msg := {
+		"type": str(parsed.type).to_int(),
+		"id": str(parsed.id).to_int(),
+		"data": parsed.data
+	}
 
-	if not peers[id].lobby: # Messages across peers are only allowed in same lobby
-		return false
+	if msg.type == Message.JOIN:
+		if peer.lobby: # Peer must not have joined a lobby already!
+			return false
+		return _join_lobby(peer, msg.data, msg.id == 0)
 
-	if not lobbies.has(peers[id].lobby): # Lobby not found?
+	if not lobbies.has(peer.lobby): # Lobby not found?
 		return false
 
-	var lobby = lobbies[peers[id].lobby]
+	var lobby = lobbies[peer.lobby]
 
-	if type.begins_with("S: "):
+	if msg.type == Message.SEAL:
 		# Client is sealing the room
-		return lobby.seal(id, server)
-
-	var dest_str: String = type.substr(3, type.length() - 3)
-	if not dest_str.is_valid_int(): # Destination id is not an integer
-		return false
+		return lobby.seal(peer.id)
 
-	var dest_id: int = int(dest_str)
-	if dest_id == NetworkedMultiplayerPeer.TARGET_PEER_SERVER:
+	var dest_id: int = msg.id
+	if dest_id == MultiplayerPeer.TARGET_PEER_SERVER:
 		dest_id = lobby.host
 
 	if not peers.has(dest_id): # Destination ID not connected
 		return false
 
-	if peers[dest_id].lobby != peers[id].lobby: # Trying to contact someone not in same lobby
+	if peers[dest_id].lobby != peer.lobby: # Trying to contact someone not in same lobby
 		return false
 
-	if id == lobby.host:
-		id = NetworkedMultiplayerPeer.TARGET_PEER_SERVER
-
-	if type.begins_with("O: "):
-		# Client is making an offer
-		server.get_peer(dest_id).put_packet(("O: %d\n%s" % [id, req[1]]).to_utf8())
-	elif type.begins_with("A: "):
-		# Client is making an answer
-		server.get_peer(dest_id).put_packet(("A: %d\n%s" % [id, req[1]]).to_utf8())
-	elif type.begins_with("C: "):
-		# Client is making an answer
-		server.get_peer(dest_id).put_packet(("C: %d\n%s" % [id, req[1]]).to_utf8())
-	return true
+	if msg.type in [Message.OFFER, Message.ANSWER, Message.CANDIDATE]:
+		var source = MultiplayerPeer.TARGET_PEER_SERVER if peer.id == lobby.host else peer.id
+		peers[dest_id].send(msg.type, source, msg.data)
+		return true
+
+	return false # Unknown message

+ 52 - 0
networking/webrtc_signaling/server_node/.eslintrc.js

@@ -0,0 +1,52 @@
+module.exports = {
+	"env": {
+		"browser": true,
+		"es2021": true,
+	},
+	"extends": [
+		"airbnb-base",
+	],
+	"parserOptions": {
+		"ecmaVersion": 12,
+	},
+	"ignorePatterns": "*.externs.js",
+	"rules": {
+		"no-console": "off",
+		"func-names": "off",
+		// Use tabs for consistency with the C++ codebase.
+		"indent": ["error", "tab"],
+		"max-len": "off",
+		"no-else-return": ["error", {allowElseIf: true}],
+		"curly": ["error", "all"],
+		"brace-style": ["error", "1tbs", { "allowSingleLine": false }],
+		"no-bitwise": "off",
+		"no-continue": "off",
+		"no-self-assign": "off",
+		"no-tabs": "off",
+		"no-param-reassign": ["error", { "props": false }],
+		"no-plusplus": "off",
+		"no-unused-vars": ["error", { "args": "none" }],
+		"prefer-destructuring": "off",
+		"prefer-rest-params": "off",
+		"prefer-spread": "off",
+		"camelcase": "off",
+		"no-underscore-dangle": "off",
+		"max-classes-per-file": "off",
+		"prefer-arrow-callback": "off",
+		// Messes up with copyright headers in source files.
+		"spaced-comment": "off",
+		// Completely breaks emscripten libraries.
+		"object-shorthand": "off",
+		// Closure compiler (exported properties)
+		"quote-props": ["error", "consistent"],
+		"dot-notation": "off",
+		// No comma dangle for functions (it's madness, and ES2017)
+		"comma-dangle": ["error", {
+			"arrays": "always-multiline",
+			"objects": "always-multiline",
+			"imports": "always-multiline",
+			"exports": "always-multiline",
+			"functions": "never"
+		}],
+	}
+};

+ 0 - 318
networking/webrtc_signaling/server_node/.eslintrc.json

@@ -1,318 +0,0 @@
-{
-    "env": {
-        "browser": true,
-        "commonjs": true,
-        "es6": true
-    },
-    "extends": "eslint:recommended",
-    "globals": {
-        "Atomics": "readonly",
-        "SharedArrayBuffer": "readonly"
-    },
-    "parserOptions": {
-        "ecmaVersion": 2018
-    },
-    "rules": {
-        "accessor-pairs": "error",
-        "array-bracket-newline": "error",
-        "array-bracket-spacing": "error",
-        "array-callback-return": "error",
-        "array-element-newline": "error",
-        "arrow-body-style": "error",
-        "arrow-parens": "error",
-        "arrow-spacing": "error",
-        "block-scoped-var": "error",
-        "block-spacing": "error",
-        "brace-style": [
-            "error",
-            "1tbs"
-        ],
-        "callback-return": "error",
-        "camelcase": "error",
-        "capitalized-comments": [
-            "error",
-            "always"
-        ],
-        "class-methods-use-this": "error",
-        "comma-dangle": "error",
-        "comma-spacing": [
-            "error",
-            {
-                "after": true,
-                "before": false
-            }
-        ],
-        "comma-style": "error",
-        "complexity": "error",
-        "computed-property-spacing": [
-            "error",
-            "never"
-        ],
-        "consistent-return": "error",
-        "consistent-this": "error",
-        "curly": "off",
-        "default-case": "error",
-        "dot-location": "error",
-        "dot-notation": "error",
-        "eol-last": "error",
-        "eqeqeq": "error",
-        "func-call-spacing": "error",
-        "func-name-matching": "error",
-        "func-names": "error",
-        "func-style": [
-            "error",
-            "declaration"
-        ],
-        "function-paren-newline": "error",
-        "generator-star-spacing": "error",
-        "global-require": "error",
-        "guard-for-in": "error",
-        "handle-callback-err": "error",
-        "id-blacklist": "error",
-        "id-length": "off",
-        "id-match": "error",
-        "implicit-arrow-linebreak": "error",
-        "indent": [
-            "error",
-            "tab"
-        ],
-        "indent-legacy": "off",
-        "init-declarations": "error",
-        "jsx-quotes": "error",
-        "key-spacing": "error",
-        "keyword-spacing": [
-            "error",
-            {
-                "after": true,
-                "before": true
-            }
-        ],
-        "line-comment-position": "off",
-        "linebreak-style": [
-            "error",
-            "unix"
-        ],
-        "lines-around-comment": "error",
-        "lines-around-directive": "error",
-        "lines-between-class-members": [
-            "error",
-            "never"
-        ],
-        "max-classes-per-file": "off",
-        "max-depth": "error",
-        "max-len": [
-            "error",
-            {
-                "code": 80,
-                "tabWidth": 8
-            }
-        ],
-        "max-lines": "error",
-        "max-lines-per-function": "error",
-        "max-nested-callbacks": "error",
-        "max-params": "error",
-        "max-statements": "off",
-        "max-statements-per-line": "error",
-        "multiline-comment-style": [
-            "error",
-            "separate-lines"
-        ],
-        "new-cap": "error",
-        "new-parens": "error",
-        "newline-after-var": "off",
-        "newline-before-return": "off",
-        "newline-per-chained-call": "error",
-        "no-alert": "error",
-        "no-array-constructor": "error",
-        "no-async-promise-executor": "error",
-        "no-await-in-loop": "error",
-        "no-bitwise": "error",
-        "no-buffer-constructor": "error",
-        "no-caller": "error",
-        "no-catch-shadow": "error",
-        "no-confusing-arrow": "error",
-	"no-console": "off",
-        "no-continue": "error",
-        "no-div-regex": "error",
-        "no-duplicate-imports": "error",
-        "no-else-return": "error",
-        "no-empty-function": "error",
-        "no-eq-null": "error",
-        "no-eval": "error",
-        "no-extend-native": "error",
-        "no-extra-bind": "error",
-        "no-extra-label": "error",
-        "no-extra-parens": "error",
-        "no-floating-decimal": "error",
-        "no-implicit-coercion": "error",
-        "no-implicit-globals": "error",
-        "no-implied-eval": "error",
-        "no-inline-comments": "off",
-        "no-inner-declarations": [
-            "error",
-            "functions"
-        ],
-        "no-invalid-this": "error",
-        "no-iterator": "error",
-        "no-label-var": "error",
-        "no-labels": "error",
-        "no-lone-blocks": "error",
-        "no-lonely-if": "error",
-        "no-loop-func": "error",
-        "no-magic-numbers": "off",
-        "no-misleading-character-class": "error",
-        "no-mixed-operators": "off",
-        "no-mixed-requires": "error",
-        "no-multi-assign": "error",
-        "no-multi-spaces": "error",
-        "no-multi-str": "error",
-        "no-multiple-empty-lines": "error",
-        "no-native-reassign": "error",
-        "no-negated-condition": "error",
-        "no-negated-in-lhs": "error",
-        "no-nested-ternary": "error",
-        "no-new": "error",
-        "no-new-func": "error",
-        "no-new-object": "error",
-        "no-new-require": "error",
-        "no-new-wrappers": "error",
-        "no-octal-escape": "error",
-        "no-param-reassign": "error",
-        "no-path-concat": "error",
-        "no-plusplus": "off",
-        "no-process-env": "error",
-        "no-process-exit": "error",
-        "no-proto": "error",
-        "no-prototype-builtins": "error",
-        "no-restricted-globals": "error",
-        "no-restricted-imports": "error",
-        "no-restricted-modules": "error",
-        "no-restricted-properties": "error",
-        "no-restricted-syntax": "error",
-        "no-return-assign": "error",
-        "no-return-await": "error",
-        "no-script-url": "error",
-        "no-self-compare": "error",
-        "no-sequences": "error",
-        "no-shadow": "error",
-        "no-shadow-restricted-names": "error",
-        "no-spaced-func": "error",
-        "no-sync": "error",
-        "no-tabs": [
-            "error",
-            {
-                "allowIndentationTabs": true
-            }
-        ],
-        "no-template-curly-in-string": "error",
-        "no-ternary": "error",
-        "no-throw-literal": "error",
-        "no-trailing-spaces": "error",
-        "no-undef-init": "error",
-        "no-undefined": "error",
-        "no-underscore-dangle": "error",
-        "no-unmodified-loop-condition": "error",
-        "no-unneeded-ternary": "error",
-        "no-unused-expressions": "error",
-        "no-use-before-define": "error",
-        "no-useless-call": "error",
-        "no-useless-catch": "error",
-        "no-useless-computed-key": "error",
-        "no-useless-concat": "error",
-        "no-useless-constructor": "error",
-        "no-useless-rename": "error",
-        "no-useless-return": "error",
-        "no-var": "error",
-        "no-void": "error",
-        "no-warning-comments": "error",
-        "no-whitespace-before-property": "error",
-        "no-with": "error",
-        "nonblock-statement-body-position": "error",
-        "object-curly-newline": "error",
-        "object-curly-spacing": [
-            "error",
-            "always"
-        ],
-        "object-property-newline": "error",
-        "object-shorthand": "error",
-        "one-var": "off",
-        "one-var-declaration-per-line": "error",
-        "operator-assignment": [
-            "error",
-            "always"
-        ],
-        "operator-linebreak": "error",
-        "padded-blocks": "off",
-        "padding-line-between-statements": "error",
-        "prefer-arrow-callback": "off",
-        "prefer-const": "error",
-        "prefer-destructuring": "error",
-        "prefer-named-capture-group": "error",
-        "prefer-numeric-literals": "error",
-        "prefer-object-spread": "error",
-        "prefer-promise-reject-errors": "error",
-        "prefer-reflect": "off",
-        "prefer-rest-params": "error",
-        "prefer-spread": "error",
-        "prefer-template": "off",
-        "quote-props": "off",
-        "quotes": "error",
-        "radix": [
-            "error",
-            "as-needed"
-        ],
-        "require-atomic-updates": "error",
-        "require-await": "error",
-        "require-jsdoc": "off",
-        "require-unicode-regexp": "error",
-        "rest-spread-spacing": "error",
-        "semi": "error",
-        "semi-spacing": [
-            "error",
-            {
-                "after": true,
-                "before": false
-            }
-        ],
-        "semi-style": [
-            "error",
-            "last"
-        ],
-        "sort-imports": "error",
-        "sort-keys": "error",
-        "sort-vars": "error",
-        "space-before-blocks": "error",
-        "space-before-function-paren": "error",
-        "space-in-parens": "error",
-        "space-infix-ops": "error",
-        "space-unary-ops": "error",
-        "spaced-comment": [
-            "error",
-            "always"
-        ],
-        "strict": [
-            "error",
-            "never"
-        ],
-        "switch-colon-spacing": "error",
-        "symbol-description": "error",
-        "template-curly-spacing": [
-            "error",
-            "never"
-        ],
-        "template-tag-spacing": "error",
-        "unicode-bom": [
-            "error",
-            "never"
-        ],
-        "valid-jsdoc": "error",
-        "vars-on-top": "error",
-        "wrap-iife": "error",
-        "wrap-regex": "error",
-        "yield-star-spacing": "error",
-        "yoda": [
-            "error",
-            "never"
-        ]
-    }
-}

+ 6 - 3
networking/webrtc_signaling/server_node/package.json

@@ -4,13 +4,16 @@
   "description": "",
   "main": "server.js",
   "dependencies": {
-    "ws": "^7.0.0"
+    "ws": "^7.5.9"
   },
   "devDependencies": {
-    "eslint": "^5.16.0"
+    "eslint": "^8.28.0",
+    "eslint-config-airbnb-base": "^14.2.1",
+    "eslint-plugin-import": "^2.23.4"
   },
   "scripts": {
-    "test": "eslint server.js && echo \"Lint OK\" && exit 0"
+    "lint": "eslint server.js && echo \"Lint OK\" && exit 0",
+    "format": "eslint server.js --fix && echo \"Lint OK\" && exit 0"
   },
   "author": "Fabio Alessandrelli",
   "license": "MIT"

+ 138 - 95
networking/webrtc_signaling/server_node/server.js

@@ -1,99 +1,129 @@
-const WebSocket = require("ws");
-const crypto = require("crypto");
+const WebSocket = require('ws');
+const crypto = require('crypto');
 
 const MAX_PEERS = 4096;
 const MAX_LOBBIES = 1024;
 const PORT = 9080;
-const ALFNUM = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
+const ALFNUM = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
 
 const NO_LOBBY_TIMEOUT = 1000;
 const SEAL_CLOSE_TIMEOUT = 10000;
 const PING_INTERVAL = 10000;
 
-const STR_NO_LOBBY = "Have not joined lobby yet";
-const STR_HOST_DISCONNECTED = "Room host has disconnected";
-const STR_ONLY_HOST_CAN_SEAL = "Only host can seal the lobby";
-const STR_SEAL_COMPLETE = "Seal complete";
-const STR_TOO_MANY_LOBBIES = "Too many lobbies open, disconnecting";
-const STR_ALREADY_IN_LOBBY = "Already in a lobby";
-const STR_LOBBY_DOES_NOT_EXISTS = "Lobby does not exists";
-const STR_LOBBY_IS_SEALED = "Lobby is sealed";
-const STR_INVALID_FORMAT = "Invalid message format";
-const STR_NEED_LOBBY = "Invalid message when not in a lobby";
-const STR_SERVER_ERROR = "Server error, lobby not found";
-const STR_INVALID_DEST = "Invalid destination";
-const STR_INVALID_CMD = "Invalid command";
-const STR_TOO_MANY_PEERS = "Too many peers connected";
-const STR_INVALID_TRANSFER_MODE = "Invalid transfer mode, must be text";
-
-function randomInt (low, high) {
+const STR_NO_LOBBY = 'Have not joined lobby yet';
+const STR_HOST_DISCONNECTED = 'Room host has disconnected';
+const STR_ONLY_HOST_CAN_SEAL = 'Only host can seal the lobby';
+const STR_SEAL_COMPLETE = 'Seal complete';
+const STR_TOO_MANY_LOBBIES = 'Too many lobbies open, disconnecting';
+const STR_ALREADY_IN_LOBBY = 'Already in a lobby';
+const STR_LOBBY_DOES_NOT_EXISTS = 'Lobby does not exists';
+const STR_LOBBY_IS_SEALED = 'Lobby is sealed';
+const STR_INVALID_FORMAT = 'Invalid message format';
+const STR_NEED_LOBBY = 'Invalid message when not in a lobby';
+const STR_SERVER_ERROR = 'Server error, lobby not found';
+const STR_INVALID_DEST = 'Invalid destination';
+const STR_INVALID_CMD = 'Invalid command';
+const STR_TOO_MANY_PEERS = 'Too many peers connected';
+const STR_INVALID_TRANSFER_MODE = 'Invalid transfer mode, must be text';
+
+const CMD = {
+	JOIN: 0, // eslint-disable-line sort-keys
+	ID: 1, // eslint-disable-line sort-keys
+	PEER_CONNECT: 2, // eslint-disable-line sort-keys
+	PEER_DISCONNECT: 3, // eslint-disable-line sort-keys
+	OFFER: 4, // eslint-disable-line sort-keys
+	ANSWER: 5, // eslint-disable-line sort-keys
+	CANDIDATE: 6, // eslint-disable-line sort-keys
+	SEAL: 7, // eslint-disable-line sort-keys
+};
+
+function randomInt(low, high) {
 	return Math.floor(Math.random() * (high - low + 1) + low);
 }
 
-function randomId () {
+function randomId() {
 	return Math.abs(new Int32Array(crypto.randomBytes(4).buffer)[0]);
 }
 
-function randomSecret () {
-	let out = "";
+function randomSecret() {
+	let out = '';
 	for (let i = 0; i < 16; i++) {
 		out += ALFNUM[randomInt(0, ALFNUM.length - 1)];
 	}
 	return out;
 }
 
+function ProtoMessage(type, id, data) {
+	return JSON.stringify({
+		'type': type,
+		'id': id,
+		'data': data || '',
+	});
+}
+
 const wss = new WebSocket.Server({ port: PORT });
 
 class ProtoError extends Error {
-	constructor (code, message) {
+	constructor(code, message) {
 		super(message);
 		this.code = code;
 	}
 }
 
 class Peer {
-	constructor (id, ws) {
+	constructor(id, ws) {
 		this.id = id;
 		this.ws = ws;
-		this.lobby = "";
+		this.lobby = '';
 		// Close connection after 1 sec if client has not joined a lobby
 		this.timeout = setTimeout(() => {
-			if (!this.lobby) ws.close(4000, STR_NO_LOBBY);
+			if (!this.lobby) {
+				ws.close(4000, STR_NO_LOBBY);
+			}
 		}, NO_LOBBY_TIMEOUT);
 	}
 }
 
 class Lobby {
-	constructor (name, host) {
+	constructor(name, host, mesh) {
 		this.name = name;
 		this.host = host;
+		this.mesh = mesh;
 		this.peers = [];
 		this.sealed = false;
 		this.closeTimer = -1;
 	}
-	getPeerId (peer) {
-		if (this.host === peer.id) return 1;
+
+	getPeerId(peer) {
+		if (this.host === peer.id) {
+			return 1;
+		}
 		return peer.id;
 	}
-	join (peer) {
+
+	join(peer) {
 		const assigned = this.getPeerId(peer);
-		peer.ws.send(`I: ${assigned}\n`);
+		peer.ws.send(ProtoMessage(CMD.ID, assigned, this.mesh ? 'true' : ''));
 		this.peers.forEach((p) => {
-			p.ws.send(`N: ${assigned}\n`);
-			peer.ws.send(`N: ${this.getPeerId(p)}\n`);
+			p.ws.send(ProtoMessage(CMD.PEER_CONNECT, assigned));
+			peer.ws.send(ProtoMessage(CMD.PEER_CONNECT, this.getPeerId(p)));
 		});
 		this.peers.push(peer);
 	}
-	leave (peer) {
+
+	leave(peer) {
 		const idx = this.peers.findIndex((p) => peer === p);
-		if (idx === -1) return false;
+		if (idx === -1) {
+			return false;
+		}
 		const assigned = this.getPeerId(peer);
 		const close = assigned === 1;
 		this.peers.forEach((p) => {
-			// Room host disconnected, must close.
-			if (close) p.ws.close(4000, STR_HOST_DISCONNECTED);
-			// Notify peer disconnect.
-			else p.ws.send(`D: ${assigned}\n`);
+			if (close) { // Room host disconnected, must close.
+				p.ws.close(4000, STR_HOST_DISCONNECTED);
+			} else { // Notify peer disconnect.
+				p.ws.send(ProtoMessage(CMD.PEER_DISCONNECT, assigned));
+			}
 		});
 		this.peers.splice(idx, 1);
 		if (close && this.closeTimer >= 0) {
@@ -103,17 +133,18 @@ class Lobby {
 		}
 		return close;
 	}
-	seal (peer) {
+
+	seal(peer) {
 		// Only host can seal
 		if (peer.id !== this.host) {
 			throw new ProtoError(4000, STR_ONLY_HOST_CAN_SEAL);
 		}
 		this.sealed = true;
 		this.peers.forEach((p) => {
-			p.ws.send("S: \n");
+			p.ws.send(ProtoMessage(CMD.SEAL, 0));
 		});
-		console.log(`Peer ${peer.id} sealed lobby ${this.name} ` +
-			`with ${this.peers.length} peers`);
+		console.log(`Peer ${peer.id} sealed lobby ${this.name} `
+			+ `with ${this.peers.length} peers`);
 		this.closeTimer = setTimeout(() => {
 			// Close peer connection to host (and thus the lobby)
 			this.peers.forEach((p) => {
@@ -126,83 +157,95 @@ class Lobby {
 const lobbies = new Map();
 let peersCount = 0;
 
-function joinLobby (peer, pLobby) {
+function joinLobby(peer, pLobby, mesh) {
 	let lobbyName = pLobby;
-	if (lobbyName === "") {
+	if (lobbyName === '') {
 		if (lobbies.size >= MAX_LOBBIES) {
 			throw new ProtoError(4000, STR_TOO_MANY_LOBBIES);
 		}
 		// Peer must not already be in a lobby
-		if (peer.lobby !== "") {
+		if (peer.lobby !== '') {
 			throw new ProtoError(4000, STR_ALREADY_IN_LOBBY);
 		}
 		lobbyName = randomSecret();
-		lobbies.set(lobbyName, new Lobby(lobbyName, peer.id));
+		lobbies.set(lobbyName, new Lobby(lobbyName, peer.id, mesh));
 		console.log(`Peer ${peer.id} created lobby ${lobbyName}`);
 		console.log(`Open lobbies: ${lobbies.size}`);
 	}
 	const lobby = lobbies.get(lobbyName);
-	if (!lobby) throw new ProtoError(4000, STR_LOBBY_DOES_NOT_EXISTS);
-	if (lobby.sealed) throw new ProtoError(4000, STR_LOBBY_IS_SEALED);
+	if (!lobby) {
+		throw new ProtoError(4000, STR_LOBBY_DOES_NOT_EXISTS);
+	}
+	if (lobby.sealed) {
+		throw new ProtoError(4000, STR_LOBBY_IS_SEALED);
+	}
 	peer.lobby = lobbyName;
-	console.log(`Peer ${peer.id} joining lobby ${lobbyName} ` +
-		`with ${lobby.peers.length} peers`);
+	console.log(`Peer ${peer.id} joining lobby ${lobbyName} `
+		+ `with ${lobby.peers.length} peers`);
 	lobby.join(peer);
-	peer.ws.send(`J: ${lobbyName}\n`);
+	peer.ws.send(ProtoMessage(CMD.JOIN, 0, lobbyName));
 }
 
-function parseMsg (peer, msg) {
-	const sep = msg.indexOf("\n");
-	if (sep < 0) throw new ProtoError(4000, STR_INVALID_FORMAT);
+function parseMsg(peer, msg) {
+	let json = null;
+	try {
+		json = JSON.parse(msg);
+	} catch (e) {
+		throw new ProtoError(4000, STR_INVALID_FORMAT);
+	}
 
-	const cmd = msg.slice(0, sep);
-	if (cmd.length < 3) throw new ProtoError(4000, STR_INVALID_FORMAT);
+	const type = typeof (json['type']) === 'number' ? Math.floor(json['type']) : -1;
+	const id = typeof (json['id']) === 'number' ? Math.floor(json['id']) : -1;
+	const data = typeof (json['data']) === 'string' ? json['data'] : '';
 
-	const data = msg.slice(sep);
+	if (type < 0 || id < 0) {
+		throw new ProtoError(4000, STR_INVALID_FORMAT);
+	}
 
 	// Lobby joining.
-	if (cmd.startsWith("J: ")) {
-		joinLobby(peer, cmd.substr(3).trim());
+	if (type === CMD.JOIN) {
+		joinLobby(peer, data, id === 0);
 		return;
 	}
 
-	if (!peer.lobby) throw new ProtoError(4000, STR_NEED_LOBBY);
+	if (!peer.lobby) {
+		throw new ProtoError(4000, STR_NEED_LOBBY);
+	}
 	const lobby = lobbies.get(peer.lobby);
-	if (!lobby) throw new ProtoError(4000, STR_SERVER_ERROR);
+	if (!lobby) {
+		throw new ProtoError(4000, STR_SERVER_ERROR);
+	}
 
 	// Lobby sealing.
-	if (cmd.startsWith("S: ")) {
+	if (type === CMD.SEAL) {
 		lobby.seal(peer);
 		return;
 	}
 
 	// Message relaying format:
 	//
-	// [O|A|C]: DEST_ID\n
-	// PAYLOAD
-	//
-	// O: Client is sending an offer.
-	// A: Client is sending an answer.
-	// C: Client is sending a candidate.
-	let destId = parseInt(cmd.substr(3).trim());
-	// Dest is not an ID.
-	if (!destId) throw new ProtoError(4000, STR_INVALID_DEST);
-	if (destId === 1) destId = lobby.host;
-	const dest = lobby.peers.find((e) => e.id === destId);
-	// Dest is not in this room.
-	if (!dest) throw new ProtoError(4000, STR_INVALID_DEST);
-
-	function isCmd (what) {
-		return cmd.startsWith(`${what}: `);
-	}
-	if (isCmd("O") || isCmd("A") || isCmd("C")) {
-		dest.ws.send(cmd[0] + ": " + lobby.getPeerId(peer) + data);
+	// {
+	//   "type": CMD.[OFFER|ANSWER|CANDIDATE],
+	//   "id": DEST_ID,
+	//   "data": PAYLOAD
+	// }
+	if (type === CMD.OFFER || type === CMD.ANSWER || type === CMD.CANDIDATE) {
+		let destId = id;
+		if (id === 1) {
+			destId = lobby.host;
+		}
+		const dest = lobby.peers.find((e) => e.id === destId);
+		// Dest is not in this room.
+		if (!dest) {
+			throw new ProtoError(4000, STR_INVALID_DEST);
+		}
+		dest.ws.send(ProtoMessage(type, lobby.getPeerId(peer), data));
 		return;
 	}
 	throw new ProtoError(4000, STR_INVALID_CMD);
 }
 
-wss.on("connection", (ws) => {
+wss.on('connection', (ws) => {
 	if (peersCount >= MAX_PEERS) {
 		ws.close(4000, STR_TOO_MANY_PEERS);
 		return;
@@ -210,8 +253,8 @@ wss.on("connection", (ws) => {
 	peersCount++;
 	const id = randomId();
 	const peer = new Peer(id, ws);
-	ws.on("message", (message) => {
-		if (typeof message !== "string") {
+	ws.on('message', (message) => {
+		if (typeof message !== 'string') {
 			ws.close(4000, STR_INVALID_TRANSFER_MODE);
 			return;
 		}
@@ -219,28 +262,28 @@ wss.on("connection", (ws) => {
 			parseMsg(peer, message);
 		} catch (e) {
 			const code = e.code || 4000;
-			console.log(`Error parsing message from ${id}:\n` +
-				message);
+			console.log(`Error parsing message from ${id}:\n${
+				message}`);
 			ws.close(code, e.message);
 		}
 	});
-	ws.on("close", (code, reason) => {
+	ws.on('close', (code, reason) => {
 		peersCount--;
-		console.log(`Connection with peer ${peer.id} closed ` +
-			`with reason ${code}: ${reason}`);
-		if (peer.lobby && lobbies.has(peer.lobby) &&
-			lobbies.get(peer.lobby).leave(peer)) {
+		console.log(`Connection with peer ${peer.id} closed `
+			+ `with reason ${code}: ${reason}`);
+		if (peer.lobby && lobbies.has(peer.lobby)
+			&& lobbies.get(peer.lobby).leave(peer)) {
 			lobbies.delete(peer.lobby);
 			console.log(`Deleted lobby ${peer.lobby}`);
 			console.log(`Open lobbies: ${lobbies.size}`);
-			peer.lobby = "";
+			peer.lobby = '';
 		}
 		if (peer.timeout >= 0) {
 			clearTimeout(peer.timeout);
 			peer.timeout = -1;
 		}
 	});
-	ws.on("error", (error) => {
+	ws.on('error', (error) => {
 		console.error(error);
 	});
 });