Browse Source

Add webrtc signaling example

Fabio Alessandrelli 5 years ago
parent
commit
2a57c64c30

+ 1 - 0
networking/webrtc_signaling/.gitignore

@@ -0,0 +1 @@
+webrtc

+ 32 - 0
networking/webrtc_signaling/README.md

@@ -0,0 +1,32 @@
+# A WebSocket signaling server/client for WebRTC.
+
+This demo is devided in 4 parts:
+
+- The `server` folder contains the signaling server implementation written in GDScript (so it can be run by a game server running Godot)
+- The `server_node` folder contains the signaling server implementation written in Node.js (if you don't plan to run a game server but only match-making).
+- The `client` part contains the client implementation in GDScript.
+  - Itself divided into raw protocol and `WebRTCMultiplayer` handling.
+- The `demo` contains a small app that uses it.
+
+**NOTE**: You must extract the [latest version](https://github.com/godotengine/webrtc-native/releases) of the WebRTC GDNative plugin in the project folder to run from desktop.
+
+## 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.

+ 86 - 0
networking/webrtc_signaling/client/multiplayer_client.gd

@@ -0,0 +1,86 @@
+extends "ws_webrtc_client.gd"
+
+var rtc_mp : WebRTCMultiplayer = WebRTCMultiplayer.new()
+var sealed = false
+
+func _init():
+	connect("connected", self, "connected")
+	connect("disconnected", self, "disconnected")
+
+	connect("offer_received", self, "offer_received")
+	connect("answer_received", self, "answer_received")
+	connect("candidate_received", self, "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")
+
+func start(url, lobby = ""):
+	stop()
+	sealed = false
+	self.lobby = lobby
+	connect_to_url(url)
+
+func stop():
+	rtc_mp.close()
+	close()
+
+func _create_peer(id : int):
+	var peer : WebRTCPeerConnection = WebRTCPeerConnection.new()
+	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])
+	rtc_mp.add_peer(peer, id)
+	if id > rtc_mp.get_unique_id():
+		peer.create_offer()
+	return peer
+
+func _new_ice_candidate(mid_name : String, index_name : int, sdp_name : String, id : int):
+	send_candidate(id, mid_name, index_name, sdp_name)
+
+func _offer_created(type : String, data : String, id : int):
+	if not rtc_mp.has_peer(id):
+		return
+	print("created", type)
+	rtc_mp.get_peer(id).connection.set_local_description(type, data)
+	if type == "offer": send_offer(id, data)
+	else: send_answer(id, data)
+
+func connected(id : int):
+	print("Connected %d" % id)
+	rtc_mp.initialize(id, true)
+
+func lobby_joined(lobby : String):
+	self.lobby = lobby
+
+func lobby_sealed():
+	sealed = true
+
+func disconnected():
+	print("Disconnected: %d: %s" % [code, reason])
+	if not sealed:
+		stop() # Unexpected disconnect
+
+func peer_connected(id : int):
+	print("Peer connected %d" % id)
+	_create_peer(id)
+
+func peer_disconnected(id : int):
+	if rtc_mp.has_peer(id): rtc_mp.remove_peer(id)
+
+func offer_received(id : int, offer : String):
+	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 : int, answer : String):
+	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 : int, mid : String, index : int, sdp : String):
+	if rtc_mp.has_peer(id):
+		rtc_mp.get_peer(id).connection.add_ice_candidate(mid, index, sdp)

+ 116 - 0
networking/webrtc_signaling/client/ws_webrtc_client.gd

@@ -0,0 +1,116 @@
+extends Node
+
+export var autojoin = true
+export var lobby = "" # Will create a new lobby if empty
+
+var client : WebSocketClient = WebSocketClient.new()
+var code = 1000
+var reason = "Unknown"
+
+signal lobby_joined(lobby)
+signal connected(id)
+signal disconnected()
+signal peer_connected(id)
+signal peer_disconnected(id)
+signal offer_received(id, offer)
+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 : String):
+	close()
+	code = 1000
+	reason = "Unknown"
+	client.connect_to_url(url)
+
+func close():
+	client.disconnect_from_host()
+
+func _closed(was_clean : bool = false):
+	emit_signal("disconnected")
+
+func _close_request(code : int, reason : String):
+	self.code = code
+	self.reason = reason
+
+func _connected(protocol = ""):
+	client.get_peer(1).set_write_mode(WebSocketPeer.WRITE_MODE_TEXT)
+	if autojoin:
+		join_lobby(lobby)
+
+func _parse_msg():
+	var pkt_str : String = client.get_peer(1).get_packet().get_string_from_utf8()
+
+	var req : PoolStringArray = 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_integer(): # 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: "):
+		# Client connected
+		emit_signal("peer_connected", src_id)
+	elif type.begins_with("D: "):
+		# Client connected
+		emit_signal("peer_disconnected", src_id)
+	elif type.begins_with("O: "):
+		# Offer received
+		emit_signal("offer_received", src_id, req[1])
+	elif type.begins_with("A: "):
+		# Answer received
+		emit_signal("answer_received", src_id, req[1])
+	elif type.begins_with("C: "):
+		# Candidate received
+		var candidate : PoolStringArray = req[1].split('\n', false)
+		if candidate.size() != 3:
+			return
+		if not candidate[1].is_valid_integer():
+			return
+		emit_signal("candidate_received", src_id, candidate[0], int(candidate[1]), candidate[2])
+
+func join_lobby(lobby : String):
+	return client.get_peer(1).put_packet(("J: %s\n" % lobby).to_utf8())
+
+func seal_lobby():
+	return client.get_peer(1).put_packet("S: \n".to_utf8())
+
+func send_candidate(id : int, mid : String, index : int, sdp : String) -> int:
+	return _send_msg("C", id, "\n%s\n%d\n%s" % [mid, index, sdp])
+
+func send_offer(id : int, offer : String) -> int:
+	return _send_msg("O", id, offer)
+
+func send_answer(id : int, answer : String) -> int:
+	return _send_msg("A", id, answer)
+
+func _send_msg(type : String, id : int, data : String) -> 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()

+ 64 - 0
networking/webrtc_signaling/demo/client_ui.gd

@@ -0,0 +1,64 @@
+extends Control
+
+onready var client = $Client
+
+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")
+
+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())
+
+func _connected(id):
+	_log("Signaling server connected with ID: %d" % id)
+
+func _disconnected():
+	_log("Signaling server disconnected: %d - %s" % [client.code, client.reason])
+
+func _lobby_joined(lobby):
+	_log("Joined lobby %s" % lobby)
+
+func _lobby_sealed():
+	_log("Lobby has been sealed")
+
+func _mp_connected():
+	_log("Multiplayer is connected (I am %d)" % client.rtc_mp.get_unique_id())
+
+func _mp_server_disconnect():
+	_log("Multiplayer is disconnected (I am %d)" % client.rtc_mp.get_unique_id())
+
+func _mp_peer_connected(id : int):
+	_log("Multiplayer peer %d connected" % id)
+
+func _mp_peer_disconnected(id : int):
+	_log("Multiplayer peer %d disconnected" % id)
+
+func _log(msg):
+	print(msg)
+	$vbox/TextEdit.text += str(msg) + "\n"
+
+func ping():
+	_log(client.rtc_mp.put_packet("ping".to_utf8()))
+
+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 start():
+	client.start($vbox/connect/host.text, $vbox/connect/RoomSecret.text)
+
+func _on_Seal_pressed():
+	client.seal_lobby()
+
+func stop():
+	client.stop()

+ 104 - 0
networking/webrtc_signaling/demo/client_ui.tscn

@@ -0,0 +1,104 @@
+[gd_scene load_steps=3 format=2]
+
+[ext_resource path="res://demo/client_ui.gd" type="Script" id=1]
+[ext_resource path="res://client/multiplayer_client.gd" type="Script" id=2]
+
+[node name="ClientUI" type="Control"]
+margin_right = 1024.0
+margin_bottom = 600.0
+size_flags_horizontal = 3
+size_flags_vertical = 3
+script = ExtResource( 1 )
+__meta__ = {
+"_edit_use_anchors_": true
+}
+
+[node name="Client" type="Node" parent="."]
+script = ExtResource( 2 )
+
+[node name="vbox" type="VBoxContainer" parent="."]
+anchor_right = 1.0
+anchor_bottom = 1.0
+__meta__ = {
+"_edit_use_anchors_": false
+}
+
+[node name="connect" type="HBoxContainer" parent="vbox"]
+margin_right = 1024.0
+margin_bottom = 24.0
+
+[node name="Label" type="Label" parent="vbox/connect"]
+margin_top = 5.0
+margin_right = 73.0
+margin_bottom = 19.0
+text = "Connect to:"
+
+[node name="host" type="LineEdit" parent="vbox/connect"]
+margin_left = 77.0
+margin_right = 921.0
+margin_bottom = 24.0
+size_flags_horizontal = 3
+text = "ws://localhost:9080"
+
+[node name="Room" type="Label" parent="vbox/connect"]
+margin_left = 925.0
+margin_right = 962.0
+margin_bottom = 24.0
+size_flags_vertical = 5
+text = "Room"
+valign = 1
+
+[node name="RoomSecret" type="LineEdit" parent="vbox/connect"]
+margin_left = 966.0
+margin_right = 1024.0
+margin_bottom = 24.0
+placeholder_text = "secret"
+
+[node name="HBoxContainer" type="HBoxContainer" parent="vbox"]
+margin_top = 28.0
+margin_right = 1024.0
+margin_bottom = 48.0
+__meta__ = {
+"_edit_use_anchors_": false
+}
+
+[node name="Start" type="Button" parent="vbox/HBoxContainer"]
+margin_right = 41.0
+margin_bottom = 20.0
+text = "Start"
+
+[node name="Stop" type="Button" parent="vbox/HBoxContainer"]
+margin_left = 45.0
+margin_right = 85.0
+margin_bottom = 20.0
+text = "Stop"
+
+[node name="Seal" type="Button" parent="vbox/HBoxContainer"]
+margin_left = 89.0
+margin_right = 127.0
+margin_bottom = 20.0
+text = "Seal"
+
+[node name="Ping" type="Button" parent="vbox/HBoxContainer"]
+margin_left = 131.0
+margin_right = 170.0
+margin_bottom = 20.0
+text = "Ping"
+
+[node name="Peers" type="Button" parent="vbox/HBoxContainer"]
+margin_left = 174.0
+margin_right = 256.0
+margin_bottom = 20.0
+text = "Print peers"
+
+[node name="TextEdit" type="TextEdit" parent="vbox"]
+margin_top = 52.0
+margin_right = 1024.0
+margin_bottom = 600.0
+size_flags_vertical = 3
+readonly = true
+[connection signal="pressed" from="vbox/HBoxContainer/Start" to="." method="start"]
+[connection signal="pressed" from="vbox/HBoxContainer/Stop" to="." method="stop"]
+[connection signal="pressed" from="vbox/HBoxContainer/Seal" to="." method="_on_Seal_pressed"]
+[connection signal="pressed" from="vbox/HBoxContainer/Ping" to="." method="ping"]
+[connection signal="pressed" from="vbox/HBoxContainer/Peers" to="." method="_on_Peers_pressed"]

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

@@ -0,0 +1,14 @@
+extends Control
+
+func _ready():
+	if OS.get_name() == "HTML5":
+		$vbox/Signaling.hide()
+
+func _on_listen_toggled(button_pressed):
+	if button_pressed:
+		$Server.listen(int($vbox/Signaling/port.value))
+	else:
+		$Server.stop()
+
+func _on_LinkButton_pressed():
+	OS.shell_open("https://github.com/godotengine/webrtc-native/releases")

+ 91 - 0
networking/webrtc_signaling/demo/main.tscn

@@ -0,0 +1,91 @@
+[gd_scene load_steps=4 format=2]
+
+[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]
+
+[node name="Control" type="Control"]
+anchor_right = 1.0
+anchor_bottom = 1.0
+script = ExtResource( 1 )
+__meta__ = {
+"_edit_use_anchors_": true
+}
+
+[node name="vbox" type="VBoxContainer" parent="."]
+anchor_right = 1.0
+anchor_bottom = 1.0
+custom_constants/separation = 50
+__meta__ = {
+"_edit_use_anchors_": true
+}
+
+[node name="Signaling" type="HBoxContainer" parent="vbox"]
+margin_right = 1024.0
+margin_bottom = 24.0
+
+[node name="Label" type="Label" parent="vbox/Signaling"]
+margin_top = 5.0
+margin_right = 104.0
+margin_bottom = 19.0
+text = "Signaling server:"
+
+[node name="port" type="SpinBox" parent="vbox/Signaling"]
+margin_left = 108.0
+margin_right = 182.0
+margin_bottom = 24.0
+min_value = 1025.0
+max_value = 65535.0
+value = 9080.0
+
+[node name="listen" type="Button" parent="vbox/Signaling"]
+margin_left = 186.0
+margin_right = 237.0
+margin_bottom = 24.0
+toggle_mode = true
+text = "Listen"
+
+[node name="CenterContainer" type="CenterContainer" parent="vbox/Signaling"]
+margin_left = 241.0
+margin_right = 1024.0
+margin_bottom = 24.0
+size_flags_horizontal = 3
+size_flags_vertical = 3
+
+[node name="LinkButton" type="LinkButton" parent="vbox/Signaling/CenterContainer"]
+margin_left = 118.0
+margin_top = 5.0
+margin_right = 664.0
+margin_bottom = 19.0
+text = "Make sure to download the GDNative WebRTC Plugin and place it in the project folder"
+
+[node name="Clients" type="GridContainer" parent="vbox"]
+margin_top = 74.0
+margin_right = 1024.0
+margin_bottom = 600.0
+size_flags_horizontal = 3
+size_flags_vertical = 3
+columns = 2
+
+[node name="ClientUI" parent="vbox/Clients" instance=ExtResource( 2 )]
+margin_right = 510.0
+margin_bottom = 261.0
+
+[node name="ClientUI2" parent="vbox/Clients" instance=ExtResource( 2 )]
+margin_left = 514.0
+margin_bottom = 261.0
+
+[node name="ClientUI3" parent="vbox/Clients" instance=ExtResource( 2 )]
+margin_top = 265.0
+margin_right = 510.0
+margin_bottom = 526.0
+
+[node name="ClientUI4" parent="vbox/Clients" instance=ExtResource( 2 )]
+margin_left = 514.0
+margin_top = 265.0
+margin_bottom = 526.0
+
+[node name="Server" type="Node" parent="."]
+script = ExtResource( 3 )
+[connection signal="toggled" from="vbox/Signaling/listen" to="." method="_on_listen_toggled"]
+[connection signal="pressed" from="vbox/Signaling/CenterContainer/LinkButton" to="." method="_on_LinkButton_pressed"]

+ 34 - 0
networking/webrtc_signaling/project.godot

@@ -0,0 +1,34 @@
+; Engine configuration file.
+; It's best edited using the editor UI and not directly,
+; since the parameters that go here are not all obvious.
+;
+; Format:
+;   [section] ; section goes between []
+;   param=value ; assign values to parameters
+
+config_version=4
+
+_global_script_classes=[  ]
+_global_script_class_icons={
+
+}
+
+[application]
+
+config/name="WebRTC Signaling Example"
+run/main_scene="res://demo/main.tscn"
+
+[debug]
+
+gdscript/warnings/shadowed_variable=false
+gdscript/warnings/unused_argument=false
+gdscript/warnings/return_value_discarded=false
+
+[gdnative]
+
+singletons=[ "res://webrtc/webrtc.tres" ]
+singletons_disabled=[  ]
+
+[network]
+
+modules/webrtc_gdnative_script="res://demo/webrtc/webrtc.gdns"

+ 195 - 0
networking/webrtc_signaling/server/ws_webrtc_server.gd

@@ -0,0 +1,195 @@
+extends Node
+
+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()
+
+class Peer extends Reference:
+	var id = -1
+	var lobby = ""
+	var time = OS.get_ticks_msec()
+
+	func _init(peer_id):
+		id = peer_id
+
+class Lobby extends Reference:
+	var peers : Array = []
+	var host : int = -1
+	var sealed : bool = false
+	var time = 0
+
+	func _init(host_id : int):
+		host = host_id
+
+	func join(peer_id : int, server : WebSocketServer) -> 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):
+				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)
+		return true
+
+	func leave(peer_id : int, server : WebSocketServer) -> bool:
+		if not peers.has(peer_id): return false
+		peers.erase(peer_id)
+		var close = false
+		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
+			if close:
+				# Disconnect peers
+				server.disconnect_peer(p)
+			else:
+				# Notify disconnection
+				server.get_peer(p).put_packet(("D: %d\n" % peer_id).to_utf8())
+		return close
+
+	func seal(peer_id : int, server : WebSocketServer) -> 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()
+		return true
+
+var rand : RandomNumberGenerator = RandomNumberGenerator.new()
+var lobbies : Dictionary = {}
+var server : WebSocketServer = WebSocketServer.new()
+var peers : Dictionary = {}
+
+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 : int):
+	stop()
+	rand.seed = OS.get_unix_time()
+	server.listen(port)
+
+func stop():
+	server.stop()
+	peers.clear()
+
+func poll():
+	if not server.is_listening():
+		return
+
+	server.poll()
+
+	# Peers timeout
+	for p in peers.values():
+		if p.lobby == "" and OS.get_ticks_msec() - p.time > TIMEOUT:
+			server.disconnect_peer(p.id)
+	# Lobby seal
+	for k in lobbies:
+		if not lobbies[k].sealed:
+			continue
+		if lobbies[k].time + SEAL_TIME < OS.get_ticks_msec():
+			# Close lobby
+			for p in lobbies[k].peers:
+				server.disconnect_peer(p)
+
+func _peer_connected(id : int, protocol = ""):
+	peers[id] = Peer.new(id)
+
+func _peer_disconnected(id : int, was_clean : bool = 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 : String) -> 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)
+	elif not lobbies.has(lobby):
+		return false
+	lobbies[lobby].join(peer.id, server)
+	peer.lobby = lobby
+	# Notify peer of its lobby
+	server.get_peer(peer.id).put_packet(("J: %s\n" % lobby).to_utf8())
+	print("Peer %d joined lobby: '%s'" % [peer.id, lobby])
+	return true
+
+func _on_data(id : int):
+	if not _parse_msg(id):
+		print("Parse message failed from peer %d" % id)
+		server.disconnect_peer(id)
+
+func _parse_msg(id : int) -> bool:
+	var pkt_str : String = server.get_peer(id).get_packet().get_string_from_utf8()
+
+	var req : PoolStringArray = pkt_str.split('\n', true, 1)
+	if req.size() != 2: # Invalid request size
+		return false
+
+	var type : String = req[0]
+	if type.length() < 3: # Invalid type size
+		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))
+
+	if not peers[id].lobby: # Messages across peers are only allowed in same lobby
+		return false
+
+	if not lobbies.has(peers[id].lobby): # Lobby not found?
+		return false
+
+	var lobby = lobbies[peers[id].lobby]
+
+	if type.begins_with("S: "):
+		# 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_integer(): # Destination id is not an integer
+		return false
+
+	var dest_id : int = int(dest_str)
+	if dest_id == NetworkedMultiplayerPeer.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
+		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

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

@@ -0,0 +1,318 @@
+{
+    "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"
+        ]
+    }
+}

+ 1 - 0
networking/webrtc_signaling/server_node/.gitignore

@@ -0,0 +1 @@
+node_modules/

+ 17 - 0
networking/webrtc_signaling/server_node/package.json

@@ -0,0 +1,17 @@
+{
+  "name": "signaling_server",
+  "version": "1.0.0",
+  "description": "",
+  "main": "server.js",
+  "dependencies": {
+    "ws": "^7.0.0"
+  },
+  "devDependencies": {
+    "eslint": "^5.16.0"
+  },
+  "scripts": {
+    "test": "eslint server.js && echo \"Lint OK\" && exit 0"
+  },
+  "author": "Fabio Alessandrelli",
+  "license": "MIT"
+}

+ 252 - 0
networking/webrtc_signaling/server_node/server.js

@@ -0,0 +1,252 @@
+const WebSocket = require("ws");
+const crypto = require("crypto");
+
+const MAX_PEERS = 4096;
+const MAX_LOBBIES = 1024;
+const PORT = 9080;
+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) {
+	return Math.floor(Math.random() * (high - low + 1) + low);
+}
+
+function randomId () {
+	return Math.abs(new Int32Array(crypto.randomBytes(4).buffer)[0]);
+}
+
+function randomSecret () {
+	let out = "";
+	for (let i = 0; i < 16; i++) {
+		out += ALFNUM[randomInt(0, ALFNUM.length - 1)];
+	}
+	return out;
+}
+
+const wss = new WebSocket.Server({ port: PORT });
+
+class ProtoError extends Error {
+	constructor (code, message) {
+		super(message);
+		this.code = code;
+	}
+}
+
+class Peer {
+	constructor (id, ws) {
+		this.id = id;
+		this.ws = ws;
+		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);
+		}, NO_LOBBY_TIMEOUT);
+	}
+}
+
+class Lobby {
+	constructor (name, host) {
+		this.name = name;
+		this.host = host;
+		this.peers = [];
+		this.sealed = false;
+		this.closeTimer = -1;
+	}
+	getPeerId (peer) {
+		if (this.host === peer.id) return 1;
+		return peer.id;
+	}
+	join (peer) {
+		const assigned = this.getPeerId(peer);
+		peer.ws.send(`I: ${assigned}\n`);
+		this.peers.forEach((p) => {
+			p.ws.send(`N: ${assigned}\n`);
+			peer.ws.send(`N: ${this.getPeerId(p)}\n`);
+		});
+		this.peers.push(peer);
+	}
+	leave (peer) {
+		const idx = this.peers.findIndex((p) => peer === p);
+		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`);
+		});
+		this.peers.splice(idx, 1);
+		if (close && this.closeTimer >= 0) {
+			// We are closing already.
+			clearTimeout(this.closeTimer);
+			this.closeTimer = -1;
+		}
+		return close;
+	}
+	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");
+		});
+		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) => {
+				p.ws.close(1000, STR_SEAL_COMPLETE);
+			});
+		}, SEAL_CLOSE_TIMEOUT);
+	}
+}
+
+const lobbies = new Map();
+let peersCount = 0;
+
+function joinLobby (peer, pLobby) {
+	let lobbyName = pLobby;
+	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 !== "") {
+			throw new ProtoError(4000, STR_ALREADY_IN_LOBBY);
+		}
+		lobbyName = randomSecret();
+		lobbies.set(lobbyName, new Lobby(lobbyName, peer.id));
+		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);
+	peer.lobby = lobbyName;
+	console.log(`Peer ${peer.id} joining lobby ${lobbyName} ` +
+		`with ${lobby.peers.length} peers`);
+	lobby.join(peer);
+	peer.ws.send(`J: ${lobbyName}\n`);
+}
+
+function parseMsg (peer, msg) {
+	const sep = msg.indexOf("\n");
+	if (sep < 0) 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 data = msg.slice(sep);
+
+	// Lobby joining.
+	if (cmd.startsWith("J: ")) {
+		joinLobby(peer, cmd.substr(3).trim());
+		return;
+	}
+
+	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);
+
+	// Lobby sealing.
+	if (cmd.startsWith("S: ")) {
+		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);
+		return;
+	}
+	throw new ProtoError(4000, STR_INVALID_CMD);
+}
+
+wss.on("connection", (ws) => {
+	if (peersCount >= MAX_PEERS) {
+		ws.close(4000, STR_TOO_MANY_PEERS);
+		return;
+	}
+	peersCount++;
+	const id = randomId();
+	const peer = new Peer(id, ws);
+	ws.on("message", (message) => {
+		if (typeof message !== "string") {
+			ws.close(4000, STR_INVALID_TRANSFER_MODE);
+			return;
+		}
+		try {
+			parseMsg(peer, message);
+		} catch (e) {
+			const code = e.code || 4000;
+			console.log(`Error parsing message from ${id}:\n` +
+				message);
+			ws.close(code, e.message);
+		}
+	});
+	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)) {
+			lobbies.delete(peer.lobby);
+			console.log(`Deleted lobby ${peer.lobby}`);
+			console.log(`Open lobbies: ${lobbies.size}`);
+			peer.lobby = "";
+		}
+		if (peer.timeout >= 0) {
+			clearTimeout(peer.timeout);
+			peer.timeout = -1;
+		}
+	});
+	ws.on("error", (error) => {
+		console.error(error);
+	});
+});
+
+const interval = setInterval(() => { // eslint-disable-line no-unused-vars
+	wss.clients.forEach((ws) => {
+		ws.ping();
+	});
+}, PING_INTERVAL);